Skip to content

feat(ifc): preserve unsupported IFC geometry - #553

Merged
Aymericr merged 252 commits into
pascalorg:mainfrom
yorhodes:codex/ifc-type-support
Sep 12, 2026
Merged

feat(ifc): preserve unsupported IFC geometry#553
Aymericr merged 252 commits into
pascalorg:mainfrom
yorhodes:codex/ifc-type-support

Conversation

@yorhodes

@yorhodes yorhodes commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add a format-neutral imported-mesh built-in node with registry-driven 3D and floorplan geometry
  • preserve unsupported or non-parametric IFC elements as serialized triangle geometry with source display colors
  • import IFC spaces as room zones and keep storey-relative elevations consistent across native and fallback nodes
  • map door families from IfcDoor.OperationType and glazing from Pset_DoorCommon.GlazingAreaFraction
  • allow browser and Node callers to configure the WebIFC WASM path
  • fix metre-normalized mesh coordinates, duplicate stair flights, malformed-space isolation, and roof-slab fallback
  • round serialized positions to 0.1 mm and normals to 0.001, and keep baked imported meshes immovable

Architecture

  • source-format parsing remains isolated in @pascal-app/ifc-converter
  • the reusable mesh schema stays in core, while Three.js and floorplan builders live in packages/nodes/src/imported-mesh
  • imported meshes are import-only and hidden from the empty-object palette
  • no viewer/editor kind-specific dispatch or project-name/material-name heuristics are introduced
  • README wording now distinguishes native parameter recovery from stair/roof hierarchy preservation

Review fixes

  • WebIFC GetFlatMesh coordinates remain in metres; only the STEP-derived origin offset is unit-scaled
  • stair-flight descendants are claimed by their native stair so they do not render twice
  • each malformed IfcSpace is isolated and missing Name values are guarded
  • roof and landing slabs skip native conversion only after a non-empty mesh has been extracted and cached
  • cleanup behavior was removed from this PR and split into fix(ifc): avoid merging adjacent wall assemblies #603 with all four fixture counts
  • imported meshes no longer advertise movable capability
  • triangle payloads are rounded before serialization

Verification

  • builds: @pascal-app/core, @pascal-app/ifc-converter, and @pascal-app/nodes
  • Biome check on all changed IFC/imported-mesh paths
  • bun test packages/ifc-converter/tests — 11 passed
  • bun test packages/core/src — passed
  • bun test packages/nodes/src — 946 passed, 1 skipped
  • 04-ifc-open-house regression: imported mesh bounds align with native wall bounds within 1 m and both roof slabs are preserved
  • duplex regression with one IfcSpace Name replaced by null: all 21 spaces import and no IFCSTAIRFLIGHT fallback remains

Note

Medium Risk
Broad release and CI/npm publishing changes plus new scene PUT rejection semantics; incorrect empty-save handling could affect integrations that relied on silent full wipes without force.

Overview
This ships the 1.0.0 release narrative in docs and tightens how Pascal is installed, validated, and saved locally.

Agent skills and MCP distribution adds versioned plugin manifests for Claude Code, Codex, and Cursor (local pascal mcp connect plus optional hosted MCP), marketplace entries, branding assets, and CI skills:validate. A new open-pr2 internal skill and metadata.internal on other agent skills keep repo workflows out of public discovery. review-architecture now references inspector slider limits and flags perpetual markDirty animation loops as blockers.

CLI and release automation extends release.yml and CI with macOS CLI smoke tests, portable editor builds, @pascal-app/cli publishing, GitHub runtime asset uploads, npm OIDC trusted publishing (replacing token auth), prerelease graduation semver, and dependency sync including dependencies.

Scene data safety blocks the “empty graph wipe” class: shared empty-graph-guard, client autosave refusal in scene-loader, and PUT /api/scenes/[id] returning 409 empty_graph_rejected unless force: true, with integration tests.

Standalone editor UX adds /import?src= (browser CORS fetch, validateBuildJson, then POST /api/scenes), registers Environment/Bones/Pool/Streetscape plugins, expands the Build tab (roof types, kitchen cabinet, MEP ToolOptionsPanel), health version/instanceId, and portable next.config standalone output.

MCP Registry workflow validates server.json against the publisher tool and live editor.pascal.app catalog/endpoint contracts.

Reviewed by Cursor Bugbot for commit 48c5ce7. Bugbot is set up for automated code reviews on this repo. Configure here.

Comment thread packages/ifc-converter/src/index.ts
Comment thread packages/ifc-converter/src/index.ts
Comment thread packages/nodes/src/imported-mesh/definition.ts
Comment thread packages/ifc-converter/src/cleanup.ts Outdated
Comment thread packages/ifc-converter/src/index.ts

@Aymericr Aymericr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this — it's tackling a real gap, and the layering is the part I most want to keep. A format-neutral imported-mesh node in core (packages/core/src/schema/nodes/imported-mesh.ts) with the Three.js and floorplan builders in packages/nodes/src/imported-mesh/ is the right shape: no kind-specific dispatch leaked into the viewer or editor, no project-name or material-name heuristics, and presentation.hidden: true genuinely keeps it out of the palette (apps/editor/components/build-tab.tsx:90 is the only consumer of that flag). Replacing the skippedBeams / skippedItems console.warn with actual preserved geometry is a clear improvement, and it gives #158 (AutoCAD) and #174 (Sweet Home 3D) a primitive to reuse later. The Pset_DoorCommon / OperationType mapping in door-semantics.ts is also the right call — standardized IFC properties instead of name sniffing — and every value it emits validates against DoorNode. Your Verification section is honest: I reproduced all of it (nodes 877 pass/1 skip, ifc-converter 9 pass, core 760 pass, duplex 245 nodes / 100 fallbacks exactly). Merge onto main is clean and the merged tree typechecks and passes biome, so the stale merge base costs nothing.

The blocker is a unit bug. extractImportedMeshPrimitives multiplies GetFlatMesh vertices by unitFactor (packages/ifc-converter/src/index.ts:684), but web-ifc already normalizes to metres — I checked by reading raw GetFlatMesh output directly, and on 04-ifc-open-house.ifc (which declares MILLIMETRE, unitFactor 0.001) the raw wall bbox is already -5.05..5.05 m. So on that file native walls land correctly at ±5 m while the imported-mesh bbox extent comes out 0.0107 x 0.0078 x 0.0058 m instead of ~10 x 3 x 8 m. Same on 10-sample-house.ifc, where all 4 zones also get ceilingHeight clamped to the 0.1 floor. Two of the four bundled fixtures are millimetre files, and I think they just weren't in the loop — everything you validated (duplex, and paris) happens to be unitFactor 1.

The fix is small: scale only originOffset (raw STEP data), not the flat-mesh coords.

if (swapYZ) {
  positions.push(
    world[0]! - originOffset[0]! * unitFactor,
    world[1]! - originOffset[2]! * unitFactor - levelElevation,
    -(world[2]! + originOffset[1]! * unitFactor),
  )
}

I applied exactly that and confirmed 04-ifc-open-house becomes 10.65 x 7.78 x 5.80 m and sample-house coverings align with the native walls, while duplex and the georeferenced paris file are byte-identical to before. Could you add a regression test asserting the imported-mesh bbox lands within a metre of the native wall bbox on a millimetre fixture? That's the guard that would have caught this.

Three more I'd want fixed before merge:

  1. Stair flights double-render. IFCSTAIRFLIGHT is in fallbackTypes (index.ts:2245) but the IFCSTAIR pass never registers child flight express IDs, so duplex emits 2 native stairs plus 2 flight meshes over the same volume. After expressIdToNodeId.set(stairExpressID, nodeId), claim the descendants from childrenMap so the fallback skips them. (Bugbot flagged this one and it's real.)

  2. space.Name null-deref silently deletes all zones. index.ts:2229 reads space.Name.value after guarding only space.Name?.value. web-ifc yields literal null for unset optionals — I verified this on duplex (Description, ObjectType, ElevationWithFlooring are all null). Because the try/catch at index.ts:2165 wraps the whole loop, one IfcSpace with LongName but no Name wipes every remaining zone in the file. Guard the read, and move the try/catch inside the loop so a malformed space skips only itself.

  3. ROOF slabs are lost, not rerouted. On 04-ifc-open-house, main emits 2 slabs ("South roof" / "North roof", both PredefinedType=ROOF); this branch emits 0 and no compensating imported-mesh appears. The skip at index.ts:1750 assumes the mesh fallback catches them and it doesn't here. Extract primitives first and only skip the native path when primitives.length > 0.

On scope: the diff has grown well past its title. Wall centerline recomputation, the 5x wall-merge tolerance cut, the material merge guard, level height/index assignment, storey inference, level-relative elevations, skylight/landing/roof skipping, unhosted-opening removal, and IfcSpace → zone are each independently risky. Two are measurably behavior-changing: the tolerance cut takes 05-paris-ground-floor from mergedWallGroups 15 / removedMergedWalls 28 to 8 / 13, leaving 100 walls where main leaves 85; and the material guard that ships with it is a no-op whenever only one fragment has a material association, since wallMaterialCompatible returns true if either side is missing (cleanup.ts:212 — also Bugbot). Could you split the cleanup.ts changes into their own PR with before/after wall counts for all four fixtures? That would let the mesh-preservation work land on its own merits.

Two smaller things worth folding in while you're here:

  • capabilities.movable will double-transform. The converter bakes level-local world coordinates into primitives and leaves position at [0,0,0], but move-registry-node-tool commits an absolute plan position and ParametricNodeRenderer applies node.position on the outer group (parametric-node-renderer.tsx:86). Simplest fix consistent with the "import-only" framing: drop movable for now and keep selectable + deletable. (Reasoned from the two code paths, not observed in the UI — worth a manual check.)
  • Payload. Positions and normals serialize as unrounded doubles, so imported-mesh is 96% of duplex's 4.48 MB scene and 91% of paris's 6.66 MB. Rounding positions to 1e-4 m (0.1 mm) and normals to 1e-3 cuts duplex to 1.97 MB with no visible change. Worth doing before this reaches hosted scene_graph rows.

One design note rather than a defect: the mesh-hull zone fallback (meshFootprint, index.ts:748) is a convex hull, so a concave or L-shaped space with no swept profile gets an inflated polygon that still counts as a successful import. I'd rather skip the zone and let the mesh preserve exact geometry, or set metadata.footprintApproximated — a wrong room boundary looks authoritative in a way a missing one doesn't.

Fix the unit scaling, the stair duplication, the Name deref, and the ROOF slabs, split out cleanup.ts, and I think this is close.

@yorhodes

yorhodes commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the requested review items in f5635ae:

  • fixed the blocker by leaving GetFlatMesh coordinates in metres and scaling only the STEP origin offset
  • added a millimetre-fixture regression that compares imported-mesh and native wall bounds within 1 m
  • claimed stair-flight descendants so duplex no longer emits IFCSTAIRFLIGHT meshes over native stairs
  • guarded missing IfcSpace Name values and isolated failures per space; a mutated duplex fixture still imports all 21 spaces
  • cached roof/landing slab primitives and only skips native slab conversion when the mesh is non-empty; open-house preserves both roof slabs
  • removed movable capability from imported meshes
  • rounded positions to 1e-4 m and normals to 1e-3
  • corrected the README so stair/roof hierarchy nodes are not described as proven full parametric conversion
  • removed all cleanup.ts behavior changes from this PR

The cleanup changes are now isolated in #603, including main-vs-branch wall counts for all four bundled fixtures. I also made material compatibility explicit when only one fragment has a material association.

Verification is updated in the PR description: converter tests 11 passed, nodes 946 passed / 1 skipped, core passed, all three affected packages build, and focused Biome checks pass.

Comment thread packages/ifc-converter/src/index.ts Outdated
Comment thread packages/ifc-converter/src/index.ts Outdated
Comment thread packages/ifc-converter/src/index.ts
@yorhodes
yorhodes requested a review from Aymericr August 5, 2026 19:55
Comment thread packages/ifc-converter/src/index.ts
@yorhodes

yorhodes commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

One scope clarification on the storey-related changes that remain in this PR:

They are coupled to imported-mesh preservation rather than being a separate cleanup pass. Pascal renders level children in level-local coordinates and stacks levels using LevelNode.height. IFC elements are not always spatially contained by an IfcBuildingStorey—roof assemblies are commonly aggregated under IfcRoof / IfcBuilding—so preserving their triangles without storey inference can leave the geometry orphaned or attached to the wrong level. Once attached, subtracting the selected storey elevation is necessary to avoid applying elevation twice when Pascal stacks the level. Ordering levels and deriving their heights from IFC storey elevations keeps native and fallback nodes in the same coordinate frame.

The inference is source-semantic only: prefer explicit IFC spatial containment; otherwise select the nearest storey at or below the element elevation, with the lowest storey as the below-grade fallback. Focused tests cover below-all, between-storeys, above-all, and no-storey cases.

I did separate the unrelated wall-cleanup behavior into #603 as requested. If you would still prefer the storey normalization as a prerequisite PR, I can split it, but #553 would then need to depend on that PR for correctly placed fallback geometry.

Comment thread packages/ifc-converter/src/door-semantics.ts
Comment thread packages/ifc-converter/src/index.ts Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread packages/ifc-converter/src/index.ts Outdated
Aymericr and others added 27 commits September 11, 2026 12:35
…lorg#851)

Avoid npm package sprawl before 1.0.0: `@pascal-app/capture-protocol`
becomes the `@pascal-app/core/capture` subpath and
`@pascal-app/capture-viewer` becomes `@pascal-app/viewer/capture` (plus
`@pascal-app/viewer/capture/preview`), so the release ships seven
packages: core, viewer, editor, nodes, mcp, ifc-converter, cli.

Neither package was ever published to npm, so no npm consumer migrates.
The protocol code is pure zod/TS, so core keeps its no-Three.js layer
rule; the runtime and its reference layers keep viewer's existing peers
and now reach viewer internals through relative imports instead of a
self-referential `@pascal-app/viewer` specifier.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…est imports it (pascalorg#852)

The registered tool lifecycle test imports @react-three/test-renderer, but
only the viewer workspace declared it. Hoisting hid the missing dependency;
private-editor CI uses Bun's isolated linker and cannot resolve that import
from the editor workspace. Declare the same ^9.1.0 development dependency
in editor and record it in the workspace lockfile entry.


Claude-Session: https://claude.ai/code/session_01Jmmz2AMwTzcnKsSHHPEMhN

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…ascalorg#853)

The 1.0.0 run failed publishing core with E404. actions/setup-node with
registry-url writes an .npmrc whose token falls back to the placeholder
XXXXX-XXXXX-XXXXX-XXXXX when NODE_AUTH_TOKEN is unset; npm sent that fake
token instead of exchanging the Actions OIDC token, and the registry
answered 404. Without registry-url no .npmrc is written and npm 11 falls
through to trusted publishing.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…lorg#856)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…arify device-path visibility (pascalorg#858)

* fix(capture): cache preview data and improve device path visibility

* fix(capture): recover failed JSON preview downloads

* test(viewer): preload one React instance before rendering hooks
…editor@1.0.0 @pascal-app/nodes@1.0.0 @pascal-app/mcp@1.0.0 @pascal-app/ifc-converter@1.0.0 @pascal-app/cli@1.0.0

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…pascalorg#859)

`@pascal-app/cli@1.0.0` is on the npm `latest` tag with `pascal agent claim`,
`pascal agent status`, and the read-only `check_collisions.candidate` input, so
the checksum-verified GitHub prerelease the docs pointed at is obsolete. Delete
the "Verified CLI preview" and "Verified GitHub preview" sections, stop
recommending the `beta` dist-tag (it still resolves to the older
`1.0.0-beta.1`), and drop the inverted claim that the npm package bundles the
web editor runtime — 1.0.0 downloads it from a release asset on first use.

Close the changelog's `Unreleased` heading as `1.0.0 (2026-09-12)` with the
package and contributor sections the earlier releases carry.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…rg#735)

manifold-3d's emscripten glue awaits import('node:module') behind a Node
check; the branch never executes in a browser, but webpack refuses to
build any graph that can reach it. export-manager.tsx statically imports
the manifold worker wrapper and ExportManager renders unconditionally
from the editor root, so every external webpack consumer of
@pascal-app/editor failed at build time (pascalorg#715).

The worker chunk is still built by the consumer's bundler, but it no
longer contains a traceable manifold-3d specifier. The glue is loaded at
runtime through an import() no bundler follows: bare specifier first
(bun tests, dev servers, bundlers that inlined it anyway), then a
version-pinned jsDelivr copy for bundled browser builds — emscripten
locates manifold.wasm relative to the glue's own URL, so the CDN path
self-resolves. configureManifoldRuntime(options) lets offline or
CSP-restricted hosts point both URLs at self-hosted assets.

A failed load no longer poisons later attempts: the cached module
promise resets on rejection.

Fixes pascalorg#715

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* docs(mcp): make batch-first apply_patch usage the stated default

Tool description, agent guide, from-brief preamble, and README now instruct agents to compose one atomic apply_patch batch per phase instead of looping single-op calls. The tool already validates all ops before applying any; only the guidance was missing.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* test(mcp): assert batch-first apply_patch guidance surfaces

Lock the tool description, agent guide, from_brief preamble, and README
row that state batch-first as the default without changing apply_patch
runtime behavior.

---------

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
pascalorg#814)

Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com>
Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com>
…alorg#848)

`bun run skills:validate` fails on Windows for every cross-file link
inside a skill bundle and for both OpenAI interface assets, even though
each referenced file exists inside the plugin. `resolve()` returns
backslash-separated paths on Windows, so the `${dir}/` prefix compared
against never matched.

Compare on a normalized separator instead, and cover the predicate with
a focused test so the check stays platform-independent.
The MCP SDK emits tool schemas with a draft-07 dialect, so clients that
enforce JSON Schema 2020-12 reject every tool call. The generated schemas
use no draft-07-only keywords, so re-registering the tools/list handler to
retarget the declared $schema is sufficient.

Fixes pascalorg#696
…org#847)

* fix(editor): skip roof support levels in the floorplan export

`resolveExportLevels()` collected every level child of the active
building and filtered on `type === 'level'` only, so a dedicated roof
support level (`metadata.role === 'roof'`) produced an extra page with
just the roof outline. `agent-guide.ts` already states that such a level
is not an occupied story, and the level UI and elevation math honour it;
the export did not.

Filter roof levels out of the export set and cover it with a regression
test, including the case where the roof level is the selected one.

Fixes pascalorg#618

* ci(release): publish through npm trusted publishing only (pascalorg#839)

The 1.0.0 release failed with EOTP on its first publish: npm no longer
accepts direct publishing with 2FA-bypass granular tokens. Drop
NODE_AUTH_TOKEN from every publish step so npm 11 exchanges the GitHub
Actions OIDC token instead. Requires each @pascal-app package to have
this repository, workflow file and the npm environment configured as a
trusted publisher on npmjs.com.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* feat(cli): ship a small CLI that downloads the web editor runtime (pascalorg#845)

The npm package carried the whole standalone Next editor: 65 MB compressed,
102 MB unpacked, for 0.1 MB of CLI code. Agents that only speak MCP paid that
cost too, because the MCP bridge started the editor to reach it.

Split the two. `dist/` now holds the CLI plus `services/pascal-mcp.mjs` and a
`runtime-source.json` naming the web runtime archive for this exact version,
its size, and its SHA-256. The web editor runtime ships as a GitHub release
asset and is downloaded once per version, verified, and installed through the
existing atomic install seam.

- MCP is its own managed service (`run/mcp.json`), started on demand by
  `pascal mcp connect` with no editor process and no runtime download.
- Commands that start the editor resolve the runtime from
  `PASCAL_BUNDLED_RUNTIME_DIR`, `--runtime <directory-or-archive>`, the
  installed version, else the release asset; a digest mismatch deletes the
  temporary file and installs nothing.
- Downloads stream over `node:https` with `HTTPS_PROXY`/`NO_PROXY` support and
  no new dependency; concurrent first runs share the install lock.
- `stage-runtime` writes a deterministic `pascal-web-runtime-<version>.tar.gz`
  plus `.sha256`; the release job verifies both before publishing and uploads
  them to the CLI tag right after it is pushed.
- The smoke test now covers MCP-only startup with no runtime present and the
  local-archive install, including a one-byte tamper that must fail closed.

Package: 0.46 MB compressed, 2.46 MB unpacked, 68 files.
Archive: 64.2 MB compressed, 106 MB installed.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* docs(skills): describe the hosted-only capture tools (pascalorg#846)

Document the hosted-only Capture scan path (list_captures, get_capture,
open_capture_as_project) in the pascal-3d skill and its tool workflows, and
scope the counted 46-tool annotation inventory to the public package so the
hosted server's extra tools do not read as a packet gap.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* feat(plugins): add optional hosted auth to the Cursor plugin (pascalorg#849)

* feat(plugins): add optional hosted auth to the Cursor plugin

A Cursor install can now reach hosted Pascal — projects, Pascal Capture
scans and shared workspaces — with an optional API key, while the
credential-free local `pascal mcp connect` server keeps working.

`.cursor-plugin/plugin.json` declares an optional `PASCAL_API_KEY`
variable and points `mcpServers` at a new Cursor-dialect
`.cursor-plugin/mcp.json` that adds a `pascal-hosted` server for
https://editor.pascal.app/api/mcp. Cursor substitutes the bare
`${PASCAL_API_KEY}` plugin-variable form from its dashboard, so the
repository holds only the placeholder. The variable is absent from
`required`, so an install with no key still loads and only
`pascal-hosted` fails (401).

The portable `mcp.json` stays credential-free on purpose. Agent Plugins
1.0.0 forbids secrets and placeholder expansion in `headers` (7.2.3,
9.2), its only remote keyword is `streamable-http` rather than Cursor's
`http`, and Codex drops a plugin-supplied `Authorization` as a
client-owned header. Codex users therefore keep using
`codex mcp add --bearer-token-env-var PASCAL_API_KEY`.

ClawHub already declares `PASCAL_API_KEY` optional through
`metadata.openclaw.envVars[].required: false`, so the skills are
unchanged.

`bun run skills:validate` now asserts the Cursor MCP path, a `pascal`
server identical to the portable one, the exact hosted URL and header
template, the optional-and-never-required variable with no unsupported
schema keywords, and that no `${VAR}` in the Cursor config is
undeclared.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(plugins): keep the Cursor author block within Cursor's schema

Cursor's plugin.json schema allows only name and email under author
(additionalProperties: false); the url field failed validation on every
install. Compare the Cursor manifest's author on those two fields and
link the changelog entry to pascalorg#849.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* perf: per-slab invalidation, candidate-scoped temporal reconciliation and rotation-stable surface planning (row 17) (pascalorg#850)

* perf(nodes): invalidate slabs by derived polygon changes

* perf(core): scope temporal reconciliation to changed nodes

* fix(nodes): mirror rendered slab context membership and order

* perf(nodes): reuse slab inputs and scope polygon derivation

* test(core): verify structural temporal reconciliation outcomes

* docs: describe temporal candidates and slab dependency tracking

* perf(core): skip disjoint room coverage and rotated surface rewrites

Cache polygon bounds for indexed surface scoping and preserve exact cyclic
outer-ring rotations in the shared slab and ceiling planners.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jmmz2AMwTzcnKsSHHPEMhN

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* editor: integrate Environment with generic host and export APIs (pascalorg#831)

* refactor(packages): fold capture packages into core and viewer (pascalorg#851)

Avoid npm package sprawl before 1.0.0: `@pascal-app/capture-protocol`
becomes the `@pascal-app/core/capture` subpath and
`@pascal-app/capture-viewer` becomes `@pascal-app/viewer/capture` (plus
`@pascal-app/viewer/capture/preview`), so the release ships seven
packages: core, viewer, editor, nodes, mcp, ifc-converter, cli.

Neither package was ever published to npm, so no npm consumer migrates.
The protocol code is pure zod/TS, so core keeps its no-Three.js layer
rule; the runtime and its reference layers keep viewer's existing peers
and now reach viewer internals through relative imports instead of a
self-referential `@pascal-app/viewer` specifier.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* fix(editor): declare @react-three/test-renderer where the lifecycle test imports it (pascalorg#852)

The registered tool lifecycle test imports @react-three/test-renderer, but
only the viewer workspace declared it. Hoisting hid the missing dependency;
private-editor CI uses Bun's isolated linker and cannot resolve that import
from the editor workspace. Declare the same ^9.1.0 development dependency
in editor and record it in the workspace lockfile entry.


Claude-Session: https://claude.ai/code/session_01Jmmz2AMwTzcnKsSHHPEMhN

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* ci(release): drop registry-url so npm uses OIDC trusted publishing (pascalorg#853)

The 1.0.0 run failed publishing core with E404. actions/setup-node with
registry-url writes an .npmrc whose token falls back to the placeholder
XXXXX-XXXXX-XXXXX-XXXXX when NODE_AUTH_TOKEN is unset; npm sent that fake
token instead of exchanging the Actions OIDC token, and the registry
answered 404. Without registry-url no .npmrc is written and npm 11 falls
through to trusted publishing.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* ci(release): log npm verbosely to surface OIDC exchange errors (pascalorg#856)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* fix(capture): cache preview artifacts, retry failed downloads, and clarify device-path visibility (pascalorg#858)

* fix(capture): cache preview data and improve device path visibility

* fix(capture): recover failed JSON preview downloads

* test(viewer): preload one React instance before rendering hooks

* release: @pascal-app/core@1.0.0 @pascal-app/viewer@1.0.0 @pascal-app/editor@1.0.0 @pascal-app/nodes@1.0.0 @pascal-app/mcp@1.0.0 @pascal-app/ifc-converter@1.0.0 @pascal-app/cli@1.0.0

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: replace the CLI preview instructions with the published npm CLI (pascalorg#859)

`@pascal-app/cli@1.0.0` is on the npm `latest` tag with `pascal agent claim`,
`pascal agent status`, and the read-only `check_collisions.candidate` input, so
the checksum-verified GitHub prerelease the docs pointed at is obsolete. Delete
the "Verified CLI preview" and "Verified GitHub preview" sections, stop
recommending the `beta` dist-tag (it still resolves to the older
`1.0.0-beta.1`), and drop the inverted claim that the npm package bundles the
web editor runtime — 1.0.0 downloads it from a release asset on first use.

Close the changelog's `Unreleased` heading as `1.0.0 (2026-09-12)` with the
package and contributor sections the earlier releases carry.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* docs: describe the release workflow and refresh the validation scope (pascalorg#860)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(editor): keep manifold-3d out of consumer bundler graphs (pascalorg#735)

manifold-3d's emscripten glue awaits import('node:module') behind a Node
check; the branch never executes in a browser, but webpack refuses to
build any graph that can reach it. export-manager.tsx statically imports
the manifold worker wrapper and ExportManager renders unconditionally
from the editor root, so every external webpack consumer of
@pascal-app/editor failed at build time (pascalorg#715).

The worker chunk is still built by the consumer's bundler, but it no
longer contains a traceable manifold-3d specifier. The glue is loaded at
runtime through an import() no bundler follows: bare specifier first
(bun tests, dev servers, bundlers that inlined it anyway), then a
version-pinned jsDelivr copy for bundled browser builds — emscripten
locates manifold.wasm relative to the glue's own URL, so the CDN path
self-resolves. configureManifoldRuntime(options) lets offline or
CSP-restricted hosts point both URLs at self-hosted assets.

A failed load no longer poisons later attempts: the cached module
promise resets on rejection.

Fixes pascalorg#715

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* mcp: make batch-first apply_patch the stated default (pascalorg#767)

* docs(mcp): make batch-first apply_patch usage the stated default

Tool description, agent guide, from-brief preamble, and README now instruct agents to compose one atomic apply_patch batch per phase instead of looping single-op calls. The tool already validates all ops before applying any; only the guidance was missing.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* test(mcp): assert batch-first apply_patch guidance surfaces

Lock the tool description, agent guide, from_brief preamble, and README
row that state batch-first as the default without changing apply_patch
runtime behavior.

---------

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* fix(editor): type optional ancestor traversal for downstream consumers (pascalorg#814)

Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com>

* fix(viewer): clamp GLB floor animation on slow frames (pascalorg#820)

Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com>

* fix(skills): compare bundle paths without a hardcoded separator (pascalorg#848)

`bun run skills:validate` fails on Windows for every cross-file link
inside a skill bundle and for both OpenAI interface assets, even though
each referenced file exists inside the plugin. `resolve()` returns
backslash-separated paths on Windows, so the `${dir}/` prefix compared
against never matched.

Compare on a normalized separator instead, and cover the predicate with
a focused test so the check stays platform-independent.

---------

Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com>
Co-authored-by: Aymeric Rabot <aymeric@pascal.app>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Wassim SAMAD <wass08@gmail.com>
Co-authored-by: Adam NAILI <18304870+AxiomeCG@users.noreply.github.com>
Co-authored-by: ActArtech <123718991+ActArtech@users.noreply.github.com>
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Co-authored-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com>
* fix(cli): preserve configured Mint host origin

* ci: enable trusted publishing for MCP and CLI releases (pascalorg#779)

* docs: add verified candidate CLI preview (pascalorg#780)

* docs: add verified candidate CLI preview

* docs: activate preview runtime during upgrades

* Require measured evidence and target-scoped furniture checks (pascalorg#781)

* Strengthen furniture fit evidence boundaries

* Record candidate validation status

* Clarify requested geometry scope

* Record furniture evidence gate results

* Document skill validation and safe preview activation (pascalorg#782)

* Record final skill validation status

* Clarify routing audit result

* Document safe preview activation

* editor: Add duct and pipe fittings, routing, and system checks (pascalorg#769)

* Add roof surface placement support for items

Items (e.g. solar panels) can now be placed on sloped roof surfaces.
The placement system computes euler rotation from the roof surface
normal so items sit flush on the slope instead of going inside.

- Add roofStrategy to placement-strategies with enter/move/click/leave
- Wire roof:enter/move/click/leave events in the placement coordinator
- Add calculateRoofRotation in placement-math using surface normals
- Support full 3D cursor rotation for sloped surfaces
- Items on roofs are parented to the level with world-space rotation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fixed conflict

* fix: pass nodes to lazy inspector panels

* MEP: unify duct and DWV routing UX

* Point editor dev runtime at local Streetscape plugin

* MEP: add exact lengths and branch affordances

* Make MEP runs surface-aware

* MEP: unify wall-aware duct and pipe run UX

- Add surface-aware drafting, snapping, and run attachments
- Support wall-attached run movement and endpoint updates
- Improve placement grid anchoring and semantic surface events

* MEP: free wall-attached routing and simplify fitting actions

- Continue routing horizontally after leaving a wall
- Keep quick material actions for pipe fittings only

* Fix duct and DWV direction capture from camera rays

* Align MEP snapping with architecture rules

* chore: satisfy repository checks

* test: scope pipe continuation handle assertion

* Add configurable MEP hangers and fix run drawing interactions

* Unify MEP accessory snapping and system connectivity

- Add shared snapping for MEP accessories with live setting updates
- Respect surfaces, levels, building transforms, and system boundaries
- Add coverage for snapping and cross-floor port connectivity

* Improve MEP connection feedback, slope controls, checks and hangers

* MEP: expand fitting catalogs and accessory configuration

- Add duct and DWV fittings, accessories, geometry, placement, and thumbnails
- Unify fitting selection through configurable tool options

* Simplify MEP build tools by removing the Add Trap action

- Remove the context-specific DWV Pipe Add Trap button from the Build tab

* Unify MEP run editing and placement UX

- Preview pipe and duct edits through live overrides
- Track fitting placement with interaction scopes
- Align surface-aware routing and accessory snapping

* Use live overrides for MEP selection previews

- Keep duct and pipe drag, roll, and offset previews out of committed scene state
- Render selection handles from live node overrides during interactions

* Simplify pipe routing status controls

* Make drafting behavior registry-driven

* Fix drafting history test registry setup

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* perf(editor): instance the ceiling corner brackets per level (pascalorg#784)

* perf(editor): instance ceiling corner brackets per level

Replace the per-corner meshes with two level-wide InstancedMeshes sharing
one unit BoxGeometry. Legs and cubes use the same geometry/material path,
so their individual transforms fit in a single batch per opacity state.
Normal instances use 0.72 opacity and highlighted instances use 0.92;
instanceColor carries the original gray/indigo colors. Three 0.185.1's
StandardNodeLibrary maps MeshBasicMaterial to MeshBasicNodeMaterial, whose
NodeMaterial.setupDiffuseColor multiplies instanceColor into material color.
This requires no custom shader or per-camera sorting.

Keep the per-ceiling drag controllers memoized and non-rendering. Geometry,
height, live overrides and preview changes update that ceiling's matrices;
hover transfers only the affected parts between packed instance arrays.
Capacity doubles with headroom on overflow, count tracks occupied slots,
and React observes the batch store only when meshes are reallocated.
Conservative expanding spheres keep native raycasts valid after writes;
frustumCulled=false prevents stale render bounds from hiding handles.
InstancedMesh.prototype.raycast remains unchanged on the pointer fast path.

Use R3F's per-instance over/out events, with move reconciliation when packed
slots change ownership. Snapshot outgoing hover targets and pointer-down
part identities, allow clicks across highlight-batch transfers at the canvas
root, and retain stable React keys so R3F transfers interaction state on
capacity growth. Preserve the level portal and registry retry, ceiling:click
payload, drag/snap/SFX/override lifecycle, and synchronous capture hiding.

Accepted visual changes from the architect ruling:
- Normal brackets render at 1000 and highlighted brackets at 1001, making
  mixed overlaps deterministically highlighted-on-top.
- Ordering against other transparent objects at 1000 is now per batch,
  using the shared geometry centre at the level origin, rather than per
  bracket. There is no per-camera instance sorting.
No other intentional behavior changes.

Validation:
- Built the local core/viewer package outputs needed for editor validation.
- packages/editor: bun test src -- 848 pass, 0 fail across 119 files.
- Includes 12 new tests for instance indexing, highlights, capacity, old
  leg matrix parity, native raycasts, and mounted R3F hover/click/drag,
  override, capture, and unmount behavior.
- packages/editor: bun run check-types (tsgo --noEmit) -- passed.
- Root: bunx biome check on all four changed files -- clean.
- Runtime draw/frame measurements and pixel comparison remain with the
  architect; no browser or dev server was started.

* fix(editor): stabilize ceiling bracket picking and resource lifetime

Resolve equal-distance bracket hits by ceiling/corner/part identity for
hover, pointer-down and click. Packed instance IDs and opacity batch order
no longer decide the owner at coincident same-height ceiling corners.
Keep native InstancedMesh raycasting and the existing click payload.

Give each batch a clone of the unit box geometry and dispose that geometry
before retiring the mesh/material on growth or teardown. WebGPU owns
instance-attribute cleanup through its geometry disposal listener.

Use StaticDrawUsage for instance matrices and colors. Writes bump versions
and add update ranges for the affected slots. Clear source ranges after
rendering because TSL uploads internal attribute wrappers; their ranges are
consumed by the backend. The attribute scheduler regression verifies that
unchanged resting frames cause no attribute updates.

Poll sceneRegistry.revision and re-resolve the level only when it changes.
Keep a stable portal group attached beneath the current level so replacing
a level object does not remount the same primitives and lose R3F event
registration. Reparenting preserves the mesh, geometry and matrix buffers.
Read the live registry object for every drag-plane query as well. Retain
the initial requestAnimationFrame retry and synchronous capture hiding.
Document that ceiling:click.position remains level-local; do not transform
or otherwise change that payload.

The reviewer's normalView/MRT concern remains uncertain: overlapping faces
with different normals may change AO/ink output when batch order changes.
No normal/MRT changes are made here. The architect will check pixels with
ink and AO enabled. The previously accepted transparency ordering remains.

Validation:
- packages/editor: bun test src -- 852 pass, 0 fail, 119 files.
- New mounted regressions cover 20 repeated moves over coincident corners,
  stable click/drag ownership, and a translated/rotated same-id level
  replacement with unchanged geometry/matrix versions and local payloads.
- Unit regressions verify geometry dispose events on growth/teardown and
  Three's WebGPU attribute scheduler skipping unchanged frames while
  changed slots carry bounded update ranges.
- bunx tsc --noEmit -p packages/editor/tsconfig.json -- exit 0, no output.
- bunx biome check on all four changed files -- clean.

* docs(editor): note accepted small bracket matrix uploads

Accept whole-array uploads on every render for small matrix buffers using Three 0.185.1’s uniform BufferNode path; above the device uniform-buffer limit, the attribute path honors versions and update ranges.

* perf(nodes): skip animation mixers for items without clips (pascalorg#785)

* feat(capture): add shared clay previews and dollhouse rendering

* docs(capture): document local previews and mesh presentation

* Split canopy regression matrix into independent tests

* feat(skills): fail closed on missing furniture inputs

* perf(nodes): batch ceiling undersides and slab bodies (charter row 16) (pascalorg#789)

* perf(nodes): batch ceiling undersides and slab bodies

* fix(nodes): close surface batch ownership and rebuild lifecycles

* fix(editor): reconcile paint previews after apply exceptions

* fix(nodes): rebuild slabs and release batches on material cache clear

* fix(nodes): strip the merged wall batch from GLB exports

* fix(editor): include moved node identity in perf receipts

* fix(editor): preserve grid surface hits while batching

* fix: preserve batched surfaces in geometry raycasts

* test(nodes): run source-system probes from a package-local file, not bun -e (pascalorg#792)

Fix private-editor CI's Lint, Typecheck & Test / Unit tests failure on Bun 1.3.0 Linux: eval probes started at the editor submodule root could not resolve @pascal-app/core from dependencies hoisted to the private root.

Write isolated probes under ignored package-local .turbo directories, resolve source imports and mocks from import.meta.dir, and remove probes in finally. Apply the same fix to the core parser test that imports zod from an eval probe. Preserve all cases and assertions.

Verified both dependency layouts, package and private-root test invocations, eval failure and file success from /tmp with automatic installs disabled, randomized nodes tests (seed 1), core parser tests, Biome, and no-emit typechecks.

* test(nodes): establish probe mocks before any fiber/react import (pascalorg#793)

* test(nodes): establish probe mocks before any fiber/react import (Bun 1.3.0)

* test(nodes): make source-system probes linker-agnostic (isolated node_modules)

* skills: make furniture follow-ups blocker-aware (pascalorg#794)

* feat(skills): add verdict-aware furniture follow-ups

* fix(skills): make furniture follow-ups blocker-aware

* fix(skills): enforce furniture action boundaries

* test(skills): pin furniture decision evidence

* docs: record agent skills 0.1.4 release source (pascalorg#795)

* docs(skills): prepare OpenAI plugin submission

* docs(skills): complete OpenAI review fixtures

* docs(skills): prepare ClawHub publication

* fix(plugin): require MCP for OpenAI submission (pascalorg#799)

* feat(mcp): add tool execution middleware

* docs(skills): record 0.1.6 as released

* fix(mcp): propagate tool cancellation

* fix(mcp): preserve executor on tool updates

* chore(skills): harden ClawHub bundles

* test(skills): reject ClawHub ignore overrides

* Add official MCP Registry publishing

* ci(mcp): verify live catalog consistency

* feat(skills): bundle local Claude MCP connector

* docs(skills): correct Claude MCP upgrade guidance

* docs(skills): record 0.1.7 release

* fix(skills): hide maintainer workflows from discovery

* fix(mcp): classify all tool side effects

* docs(openai): add tool annotation justifications (pascalorg#813)

* feat(cli): add hosted agent claim command (pascalorg#815)

* feat(cli): add hosted agent claim command

* fix(cli): require canonical claim expiry

* fix(ci): authenticate CLI npm publish (pascalorg#816)

* fix(ci): restore OIDC for CLI publishing (pascalorg#817)

* feat(cli): prefill hosted agent claim (pascalorg#818)

* docs(cli): publish verified agent claim preview (pascalorg#819)

* feat(cli): report hosted agent status (pascalorg#821)

* docs(cli): publish verified agent status preview (pascalorg#822)

* feat(skills): add human-openable fit prechecks (pascalorg#824)

* docs(skills): record agent report release evidence (pascalorg#825)

* perf: scope undo/redo invalidation to changed geometry and cleared previews (charter row 7) (pascalorg#805)

* Scope undo invalidation to changed geometry and cleared previews

* Reset editor state before randomized store tests

* Resolve history probe mocks from each consuming package

* Restore discarded preview dependency closures on undo and redo

* Limit rendered slab invalidation to changed boundary bands

* Cover history support transfers and scoped endpoint rebuilds

* Pin endpoint history closure with spatial sync mounted

* Run package tests against core source without rebuilding dist

* test: drop the repo-wide core source preload

* test: verify consecutive undo and redo invalidation

Zundo 2.3.0 appends the just-left snapshot to both destination stacks, so the existing pre-jump length indices are correct. Cover three adjacency-changing moves and each undo/redo with cleared marks and flushed microtasks.

* fix: invalidate old slab covering dependents on reparent

Refresh covering dependents below both parent levels, deduplicating equal resolved levels. Cover reparent from level 2 to level 3 and undo with exact wall/ceiling sets and unrelated levels left clean.

* perf: drain initial wall builds within the time budget (charter row 6) (pascalorg#800)

* perf: drain initial wall builds within the time budget

* fix(core): invalidate hydration atomically with scene edits

* test: isolate scene fixtures from randomized ordering

* fix(core): complete normalization before publishing hydration

* fix(viewer): preserve and bound initial wall drain lifetime

* docs: clarify hydration lifetime and wall drain counters

* Experience fix pass: placement, selection rotation, roof, stairs, capture, Cmd+S, three 0.186 (pascalorg#807)

* fix(capture): round armed FOV, add Alt slow modifier for the drone camera

armCaptureFov stored the live camera FOV verbatim, so fractional pose FOVs
printed float tails in the HUD and left the reset button enabled. Both
writers now share clampCaptureFov.

Alt holds the drone at 0.2x speed and look sensitivity for fine framing;
Shift stays the boost.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* fix(roof): keep the gable shell base on the wall top

The CSG degeneracy guard enforced its 5 cm minimum by lowering the shell
base, which for wallHeight-0 room roofs put the gable 4 cm inside the
wall and z-fought its faces. Raise the eave instead; mirror the floor in
the opening-placement frame and the shed inset panel.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* feat(editor): Cmd/Ctrl+S saves instead of opening the browser dialog

Capture-phase, always-on listener so the page-save dialog never appears.
Hosts can take the chord over via onSaveShortcut; the default flushes the
autosave through the existing executeSave path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* fix(tools): anchor composite presets at their footprint centre, lift previews to the level

Fresh (absolute) placement mapped the cursor to the node origin, so a
cabinet run landed |bounds.center| away from the pointer. Subtract the
rotated centre and keep it under the cursor across R/T. The registry
mover's box/sphere now ride the target level's stacked Y like the other
placement tools.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* fix(stair): follow the storey height from the elected base

Level-destination stairs returned the full floor-to-floor height even
when a slab lifted their base, so the top overshot the storey plane. The
resolver now subtracts the elected base for both destinations. The panel
exposes Follows storey / Custom rise for level stairs, and the stair tool
and landing toggle seed from the storey instead of a 2.5 m constant.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* fix(item): drop un-hosted items to the floor, draw the placement box on the right storey

The floor-path Y was frozen at drag start (pascalorg#638), so an item pulled off a
shelf kept the shelf height after reparenting. Read the live grid Y
instead. The cursor group, grid surface and facing pose now add the
level mesh's stacked Y, which the building-local tool group lacks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* fix(selection): keep member rotation when pressing R/T mid-drag

translateGroupPatches dropped the snapshots' yaw after a mid-gesture
rotation, so the layout orbited while every item kept its old facing and
the commit wrote the same. Carry rotation for vec3/scalar participants,
pivot every session on the shared mesh-box centre the idle shortcut
uses, and engage an armed session before rotating.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* chore(deps): three 0.186.0

No removed export is used and every peer range admits r186. Two
adjustments: Renderer.dispose() is async now, so the capability probe
swallows its rejection; and r186's CommonJS entry re-exports the ES
module, which Bun cannot require() while the same process imports three
as ESM. A bun test preload steers fiber/drei/maath/meshline (no exports
map, CJS main) to their module builds, the way bundlers already resolve
them. Types stay on 0.184.1 (0.185 types OOM tsgo).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* test: pre-evaluate three in the bun test preload

Bun's plugin onResolve does not run for static imports, so steering the
R3F packages to their module builds never applied in CI (isolated linker)
and the CJS require("three") kept racing the ESM import. Evaluating the
package's own three copy first makes the later require() a cache hit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* fix(roof): lift the inner cutter and deck with the shell eave

The 5 cm CSG floor lifted only the outer shell, so a flat zero-height
roof would have ended up with a solid cap under the deck. Compute the
lift once and apply it to every volume.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* fix(editor): fall back to the autosave flush when the host does not handle Cmd+S

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* fix(roof): floor every prism at 5 cm instead of lifting by the shell's eave

A shell-derived lift left overhanging deck cutters with a negative eave.
Clamp each volume's top the way main did, just at 5 cm and without the
base sink, so cutters stay level with the shells they carve.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* fix(stair): drop the duplicate geometry Rise control

The rise-mode block already exposes the Rise field in custom mode; the
geometry copy wrote totalRise behind the Follows storey toggle.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* test: import resolveSync explicitly in the three preload

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* test: skip the three preload where the cwd has no three dependency

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* fix(editor): seed placed stairs from the elected base; keep the gesture when R/T cannot engage

The stair tool seeded the flight from the storey height alone, a slab
thickness too tall until syncStairRises caught up; it now subtracts the
drop point's elected base like the resolver. A failed engage() on R/T no
longer tears down the pointer listeners.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* fix(stair): cap the placed rise by the pointed support surface

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* fix: scale the stair ghost to the placed rise; await renderer.dispose() before the WebGL fallback

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* fix(stair): read the placed rise from the preview scene

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* fix(selection): re-fit alignment bounds from the start footprint after each R/T

Rotating the previous axis-aligned fit inflated the anchors every step.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* fix(stair): switching to straight materializes a flight; level labels use the shared display name

A curved stair switched to straight had no stair-segment child and drew
nothing (and vanished on select). The type change now creates a default
flight in the same history step and the viewer falls back to that flight
for already-broken scenes. Stair and elevator panels label levels the way
the level switcher does, and the rise toggle reads Follows level like walls.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* feat(roof): wall-footprint roofs follow their source walls' tops

Room roofs computed their elevation once at creation, so a later custom
wall height left the roof at the storey plane. Roofs now remember their
source walls and a core system re-derives position[1] (highest top,
clamped to the level floor) on wall/slab/level edits, history-paused like
the stair rise sync. Moving the roof by hand detaches it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* fix(stair): a flight height edit pins the parent stair to the new total rise

On a follows-level stair the sync handed the edited height straight back,
so the segment slider did nothing. The edit now also writes totalRise
(the stair becomes Custom rise, as editing Rise on its own panel does).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* fix(roof): follow wall tops below the storey plane

Walls shorter than the level (2.5 m in a 3 m storey) left a gap because
the roof elevation was clamped to its level floor. Follow the wall top.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* feat(roof): follow walls by intent, resolved from the footprint

Replace the source-wall id list with support.kind 'walls': room and
conical roofs are created following, the system resolves the enclosure
under the roof centre on the level below and writes the highest wall top
(unclamped), an explicit Y edit or vertical handle drag flips the roof to
custom, and the panel offers Follows walls / Custom like walls do. No
migration; existing roofs stay custom until the user opts in.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* fix(roof): pick supporting walls by footprint overlap, not closed-room membership

A room missing a wall, or an L-room whose centre falls outside, left the
roof frozen. Walls whose band overlaps a segment footprint on the level
below now count; segment-less roofs keep the point-in-room lookup.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* fix(roof): parent room roofs to the storey above their walls; follow walls on the roof's own level too

Armed on the walls' level, the tool parented the roof to that level and
the follow rule only looked one storey down, so a Floor 1 roof dropped to
the Level 0 wall tops. The roof now goes to the level above the walls
when one exists (top floor keeps it on the walls' level), and the
resolver considers walls on the roof's level and the one below.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* test(editor): resolve history probe modules from packages that depend on them (isolated linker) (pascalorg#828)

* test(editor): resolve history probe modules from packages that depend on them (isolated linker)

* test(nodes): keep the lean-to canopy angle sweep under the per-test timeout on slow runners

* feat(editor): streamline connected pipe and duct drafting (pascalorg#827)

* Add roof surface placement support for items

Items (e.g. solar panels) can now be placed on sloped roof surfaces.
The placement system computes euler rotation from the roof surface
normal so items sit flush on the slope instead of going inside.

- Add roofStrategy to placement-strategies with enter/move/click/leave
- Wire roof:enter/move/click/leave events in the placement coordinator
- Add calculateRoofRotation in placement-math using surface normals
- Support full 3D cursor rotation for sloped surfaces
- Items on roofs are parented to the level with world-space rotation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fixed conflict

* fix: pass nodes to lazy inspector panels

* feat: add immersive WebXR editor support

* chore: remove WebXR integration

* chore: remove WebXR support

* chore: checkpoint existing editor work before inline insertion

* docs: track inline insertion implementation steps

* docs: record inline insertion domain contract completion

* feat: add inline pipe fitting insertion and run snapping

- Split pipe runs around inline fittings with preserved connections
- Improve run snapping, marquee selection, and rotation shortcut ownership

* feat: route insertion tools through registry scene context

- Add screen-space projection data for cross-view snapping
- Use registry scene APIs for atomic node changes and selection

* feat: keep run end caps aligned during endpoint moves

- Update mated duct and pipe end caps as endpoints move
- Cache shared handle geometry and materials
- Remove redundant connection and snap labels

* fix: scale run direction feedback geometry

- Preserve ray and arrow dimensions while using unit-sized shared geometry

* fix: resolve architecture review findings

* fix(cli): trim vendored archives from runtime

* fix(nodes): preserve automatic end cap ownership

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Wassim SAMAD <wass08@gmail.com>

* skills: portable mcp.json, channel manifests, validator parity (pascalorg#829)

* chore(skills): portable mcp.json, channel manifests, validator parity

Add the root mcp.json the Agent Plugins spec fixes for Codex and Cursor
(previously only .mcp.json shipped, so those hosts installed the skills
without the MCP server), a Gemini CLI extension manifest, and repository
and icons on server.json. Make plugin.json the single bundle version
source and assert name, version, description and author parity across
all five descriptors, mcp.json/.mcp.json equality, the Claude marketplace
skill set, the documented OpenAI interface fields, byte-identical
.clawhubignore files, and fragment-aware links across skills/README.md
and VALIDATION.md. Fix the broken anchor to the verified GitHub preview,
the 0.1.7 release-notes version, the Cursor snippets to
${env:PASCAL_API_KEY}, add bun run skills:validate for CI and docs,
drop the version literal from mcp-registry.yml, add the skills.sh badge,
the shell-history caveat, and CHANGELOG entries for the distribution work.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore(skills): add Cursor manifest and plate logo for marketplaces

Directory forms want a 1:1 logo on a background plate, and Cursor's
checklist wants it committed and referenced by relative path. Add the
brand mark on its #171717 plate as assets/pascal-mark-plate.svg and the
byte-identical brand-kit 1024 px PNG, point the OpenAI logo at the plate
SVG (composerIcon keeps the transparent mark), add
.cursor-plugin/plugin.json with Cursor-native fields, list the 1024 icon
on server.json, and assert the Cursor manifest's parity and paths in the
validator.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* skills: make skills/ the Claude plugin root (pascalorg#832)

* fix(skills): make skills/ the Claude plugin root

The Claude marketplace entry sourced the plugin from the repository root,
so every `/plugin install pascal-agent-skills@pascal` copied the whole
monorepo into the plugin cache and, because that root carries package.json
next to bun.lock, ran `bun install --frozen-lockfile --ignore-scripts`
against it on every install and update (60 s timeout, not disableable). A
fresh install produced a 1.2 GB cache, 1.1 GB of it node_modules, to
deliver two markdown skill bundles.

Point the marketplace entry at ./skills and move the Claude plugin manifest
and the bundled local `pascal mcp connect` configuration into that root; a
plugin cannot reference files above its own root, so both have to live
inside skills/. The manifest lists the bundles explicitly because the
default skills/ scan no longer applies once skills/ is itself the root. A
fresh install is now 196 KB with no node_modules, package.json, or
packages/. skills.sh tree URLs, the Codex and Cursor Agent Plugins layout,
Gemini and ClawHub still read the root plugin.json, root mcp.json, and the
same skills/ tree.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore(skills): list the plugin as Pascal in directories

Directory listings show the display name next to product plugins listed
by brand, so use the brand rather than "Pascal agent skills" across the
Claude, Cursor and OpenAI manifests. The identifier stays
pascal-agent-skills.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(changelog): link the plugin-root fix to pascalorg#832

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* skills: bundle the hosted Pascal MCP server with a key prompt (pascalorg#835)

* feat(plugin): add hosted MCP server to the Claude Code plugin

The plugin only bundled the local `pascal mcp connect` stdio server, so a
Claude Code user with a Pascal account had to leave the plugin and run
`claude mcp add` by hand before touching a hosted project or a Capture scan.
Declaring the key as `userConfig.pascal_api_key` lets Claude Code collect it
in the enable-time prompt and substitute it into the `pascal-hosted` server's
Authorization header, so the hosted tools arrive with the skills.

The option is `sensitive` so Claude Code stores the key in the OS keychain
instead of settings.json, and `required: false` so a local-only install still
works with the field left empty.

`${user_config.*}` is a Claude Code substitution, so the hosted server cannot
live in the portable Agent Plugins `mcp.json` that Codex and Cursor read. The
validators now enforce that split: the `pascal` server must be byte-identical
in both files, `skills/.mcp.json` may add only `pascal-hosted`, and the hosted
Authorization header must stay a `user_config` reference so no literal
credential can ship in the published plugin source.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(changelog): link the hosted MCP entry to pascalorg#835

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* ci(release): graduate prerelease versions on stable bumps (pascalorg#837)

The stable-bump path split "1.0.0-beta.5" on dots, so major produced
2.0.0, minor 1.1.0, patch failed on "0-beta" arithmetic, and none would
have published a beta version on the latest dist-tag. Any stable bump on
a prerelease now yields its base version, matching npm semver, so the
1.0.0-beta.N line can be released as 1.0.0.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* perf(cli): prune build-only files from the portable runtime (pascalorg#838)

`next build` copies its tracing root into `.next/standalone`, so the staged
runtime shipped app sources, repository documentation, build trace metadata and
assets that `server.js` never reads.

Pruned from `dist/runtime`:

- `public/audios/radios` (39.3 MB) — the radio catalogue is played by the hosted
  community app, which serves its own copy; nothing in this repository requests
  `/audios/radios`.
- `next/dist/server/capsize-font-metrics.json` + `font-utils.js` (4.1 MB) —
  `font-utils.js` is the only reader of the metrics and is itself unreachable
  from the standalone server.
- A stray 3.15 MB authoring screenshot and a duplicate `.glb` under
  `public/items` — item assets are addressed by convention, and anything else is
  now dropped and named on stdout.
- `apps/editor/{app,components,lib}` plus dev-only configuration and docs
  (0.5 MB) — TypeScript sources and tests that Node never executes.
- `.nft.json` build trace metadata and source maps under `.next` (0.7 MB).

Before: 107.5 MB tarball, 149.2 MB unpacked, 2956 files.
After:   64.6 MB tarball, 101.7 MB unpacked, 2870 files.

The release budget in the smoke test drops to 75 MB / 115 MB / 3200 files so the
regression cannot come back unnoticed. `stage-runtime` + `smoke-runtime` pass,
and the packed CLI still serves the editor, `/scenes`, a scene page with all 84
of its static chunks, and every sampled public asset.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* perf(editor): re-encode fitting thumbnails as 256px webp (pascalorg#842)

`public/icons/fittings/` held 16 PNGs at 1254x1254 RGBA — 11 MB of assets
for thumbnails that render at 56 CSS px in the MEP tool options grid, and
11 MB of the 64.6 MB packed CLI runtime. Every other icon under
`public/icons` is already a small webp.

Each PNG becomes a 256x256 lossy webp with alpha (`cwebp -q 85 -m 6
-alpha_q 100 -resize 256 256`), which is still 2.3x the largest rendered
size — the portable build sets `images.unoptimized`, so the raw file is
what the browser scales. The directory drops from 11 MB to 164 KB.

`build-tab.tsx` derives the path from the fitting type, so the extension
in that template is the only reference to update.

Packed runtime smoke: 54.1 MB compressed, 91.0 MB unpacked, 2870 files
(was 64.6 MB / 101.7 MB / 2870).

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* chore(editor): drop unreferenced public assets (pascalorg#843)

Remove 53 MB of committed assets nothing in this repository reads: the
small-kitchen-cabinet item (10.9 MB; the item catalog resolves every item
from remote storage and no demo references this slug), a stray authoring
screenshot, and the radio catalogue (39 MB) that only the hosted community
app plays from its own copy. The CLI staging script already pruned the
radios and the screenshot; its rm(force) calls tolerate their absence.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* ci(release): publish through npm trusted publishing only (pascalorg#839)

The 1.0.0 release failed with EOTP on its first publish: npm no longer
accepts direct publishing with 2FA-bypass granular tokens. Drop
NODE_AUTH_TOKEN from every publish step so npm 11 exchanges the GitHub
Actions OIDC token instead. Requires each @pascal-app package to have
this repository, workflow file and the npm environment configured as a
trusted publisher on npmjs.com.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* feat(cli): ship a small CLI that downloads the web editor runtime (pascalorg#845)

The npm package carried the whole standalone Next editor: 65 MB compressed,
102 MB unpacked, for 0.1 MB of CLI code. Agents that only speak MCP paid that
cost too, because the MCP bridge started the editor to reach it.

Split the two. `dist/` now holds the CLI plus `services/pascal-mcp.mjs` and a
`runtime-source.json` naming the web runtime archive for this exact version,
its size, and its SHA-256. The web editor runtime ships as a GitHub release
asset and is downloaded once per version, verified, and installed through the
existing atomic install seam.

- MCP is its own managed service (`run/mcp.json`), started on demand by
  `pascal mcp connect` with no editor process and no runtime download.
- Commands that start the editor resolve the runtime from
  `PASCAL_BUNDLED_RUNTIME_DIR`, `--runtime <directory-or-archive>`, the
  installed version, else the release asset; a digest mismatch deletes the
  temporary file and installs nothing.
- Downloads stream over `node:https` with `HTTPS_PROXY`/`NO_PROXY` support and
  no new dependency; concurrent first runs share the install lock.
- `stage-runtime` writes a deterministic `pascal-web-runtime-<version>.tar.gz`
  plus `.sha256`; the release job verifies both before publishing and uploads
  them to the CLI tag right after it is pushed.
- The smoke test now covers MCP-only startup with no runtime present and the
  local-archive install, including a one-byte tamper that must fail closed.

Package: 0.46 MB compressed, 2.46 MB unpacked, 68 files.
Archive: 64.2 MB compressed, 106 MB installed.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* docs(skills): describe the hosted-only capture tools (pascalorg#846)

Document the hosted-only Capture scan path (list_captures, get_capture,
open_capture_as_project) in the pascal-3d skill and its tool workflows, and
scope the counted 46-tool annotation inventory to the public package so the
hosted server's extra tools do not read as a packet gap.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* feat(plugins): add optional hosted auth to the Cursor plugin (pascalorg#849)

* feat(plugins): add optional hosted auth to the Cursor plugin

A Cursor install can now reach hosted Pascal — projects, Pascal Capture
scans and shared workspaces — with an optional API key, while the
credential-free local `pascal mcp connect` server keeps working.

`.cursor-plugin/plugin.json` declares an optional `PASCAL_API_KEY`
variable and points `mcpServers` at a new Cursor-dialect
`.cursor-plugin/mcp.json` that adds a `pascal-hosted` server for
https://editor.pascal.app/api/mcp. Cursor substitutes the bare
`${PASCAL_API_KEY}` plugin-variable form from its dashboard, so the
repository holds only the placeholder. The variable is absent from
`required`, so an install with no key still loads and only
`pascal-hosted` fails (401).

The portable `mcp.json` stays credential-free on purpose. Agent Plugins
1.0.0 forbids secrets and placeholder expansion in `headers` (7.2.3,
9.2), its only remote keyword is `streamable-http` rather than Cursor's
`http`, and Codex drops a plugin-supplied `Authorization` as a
client-owned header. Codex users therefore keep using
`codex mcp add --bearer-token-env-var PASCAL_API_KEY`.

ClawHub already declares `PASCAL_API_KEY` optional through
`metadata.openclaw.envVars[].required: false`, so the skills are
unchanged.

`bun run skills:validate` now asserts the Cursor MCP path, a `pascal`
server identical to the portable one, the exact hosted URL and header
template, the optional-and-never-required variable with no unsupported
schema keywords, and that no `${VAR}` in the Cursor config is
undeclared.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(plugins): keep the Cursor author block within Cursor's schema

Cursor's plugin.json schema allows only name and email under author
(additionalProperties: false); the url field failed validation on every
install. Compare the Cursor manifest's author on those two fields and
link the changelog entry to pascalorg#849.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* perf: per-slab invalidation, candidate-scoped temporal reconciliation and rotation-stable surface planning (row 17) (pascalorg#850)

* perf(nodes): invalidate slabs by derived polygon changes

* perf(core): scope temporal reconciliation to changed nodes

* fix(nodes): mirror rendered slab context membership and order

* perf(nodes): reuse slab inputs and scope polygon derivation

* test(core): verify structural temporal reconciliation outcomes

* docs: describe temporal candidates and slab dependency tracking

* perf(core): skip disjoint room coverage and rotated surface rewrites

Cache polygon bounds for indexed surface scoping and preserve exact cyclic
outer-ring rotations in the shared slab and ceiling planners.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jmmz2AMwTzcnKsSHHPEMhN

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* editor: integrate Environment with generic host and export APIs (pascalorg#831)

* refactor(packages): fold capture packages into core and viewer (pascalorg#851)

Avoid npm package sprawl before 1.0.0: `@pascal-app/capture-protocol`
becomes the `@pascal-app/core/capture` subpath and
`@pascal-app/capture-viewer` becomes `@pascal-app/viewer/capture` (plus
`@pascal-app/viewer/capture/preview`), so the release ships seven
packages: core, viewer, editor, nodes, mcp, ifc-converter, cli.

Neither package was ever published to npm, so no npm consumer migrates.
The protocol code is pure zod/TS, so core keeps its no-Three.js layer
rule; the runtime and its reference layers keep viewer's existing peers
and now reach viewer internals through relative imports instead of a
self-referential `@pascal-app/viewer` specifier.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* fix(editor): declare @react-three/test-renderer where the lifecycle test imports it (pascalorg#852)

The registered tool lifecycle test imports @react-three/test-renderer, but
only the viewer workspace declared it. Hoisting hid the missing dependency;
private-editor CI uses Bun's isolated linker and cannot resolve that import
from the editor workspace. Declare the same ^9.1.0 development dependency
in editor and record it in the workspace lockfile entry.


Claude-Session: https://claude.ai/code/session_01Jmmz2AMwTzcnKsSHHPEMhN

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* ci(release): drop registry-url so npm uses OIDC trusted publishing (pascalorg#853)

The 1.0.0 run failed publishing core with E404. actions/setup-node with
registry-url writes an .npmrc whose token falls back to the placeholder
XXXXX-XXXXX-XXXXX-XXXXX when NODE_AUTH_TOKEN is unset; npm sent that fake
token instead of exchanging the Actions OIDC token, and the registry
answered 404. Without registry-url no .npmrc is written and npm 11 falls
through to trusted publishing.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* ci(release): log npm verbosely to surface OIDC exchange errors (pascalorg#856)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* fix(capture): cache preview artifacts, retry failed downloads, and clarify device-path visibility (pascalorg#858)

* fix(capture): cache preview data and improve device path visibility

* fix(capture): recover failed JSON preview downloads

* test(viewer): preload one React instance before rendering hooks

* release: @pascal-app/core@1.0.0 @pascal-app/viewer@1.0.0 @pascal-app/editor@1.0.0 @pascal-app/nodes@1.0.0 @pascal-app/mcp@1.0.0 @pascal-app/ifc-converter@1.0.0 @pascal-app/cli@1.0.0

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: replace the CLI preview instructions with the published npm CLI (pascalorg#859)

`@pascal-app/cli@1.0.0` is on the npm `latest` tag with `pascal agent claim`,
`pascal agent status`, and the read-only `check_collisions.candidate` input, so
the checksum-verified GitHub prerelease the docs pointed at is obsolete. Delete
the "Verified CLI preview" and "Verified GitHub preview" sections, stop
recommending the `beta` dist-tag (it still resolves to the older
`1.0.0-beta.1`), and drop the inverted claim that the npm package bundles the
web editor runtime — 1.0.0 downloads it from a release asset on first use.

Close the changelog's `Unreleased` heading as `1.0.0 (2026-09-12)` with the
package and contributor sections the earlier releases carry.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* docs: describe the release workflow and refresh the validation scope (pascalorg#860)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(editor): keep manifold-3d out of consumer bundler graphs (pascalorg#735)

manifold-3d's emscripten glue awaits import('node:module') behind a Node
check; the branch never executes in a browser, but webpack refuses to
build any graph that can reach it. export-manager.tsx statically imports
the manifold worker wrapper and ExportManager renders unconditionally
from the editor root, so every external webpack consumer of
@pascal-app/editor failed at build time (pascalorg#715).

The worker chunk is still built by the consumer's bundler, but it no
longer contains a traceable manifold-3d specifier. The glue is loaded at
runtime through an import() no bundler follows: bare specifier first
(bun tests, dev servers, bundlers that inlined it anyway), then a
version-pinned jsDelivr copy for bundled browser builds — emscripten
locates manifold.wasm relative to the glue's own URL, so the CDN path
self-resolves. configureManifoldRuntime(options) lets offline or
CSP-restricted hosts point both URLs at self-hosted assets.

A failed load no longer poisons later attempts: the cached module
promise resets on rejection.

Fixes pascalorg#715

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* mcp: make batch-first apply_patch the stated default (pascalorg#767)

* docs(mcp): make batch-first apply_patch usage the stated default

Tool description, agent guide, from-brief preamble, and README now instruct agents to compose one atomic apply_patch batch per phase instead of looping single-op calls. The tool already validates all ops before applying any; only the guidance was missing.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* test(mcp): assert batch-first apply_patch guidance surfaces

Lock the tool description, agent guide, from_brief preamble, and README
row that state batch-first as the default without changing apply_patch
runtime behavior.

---------

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* fix(editor): type optional ancestor traversal for downstream consumers (pascalorg#814)

Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com>

* fix(viewer): clamp GLB floor animation on slow frames (pascalorg#820)

Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com>

* fix(skills): compare bundle paths without a hardcoded separator (pascalorg#848)

`bun run skills:validate` fails on Windows for every cross-file link
inside a skill bundle and for both OpenAI interface assets, even though
each referenced file exists inside the plugin. `resolve()` returns
backslash-separated paths on Windows, so the `${dir}/` prefix compared
against never matched.

Compare on a normalized separator instead, and cover the predicate with
a focused test so the check stays platform-independent.

---------

Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com>
Co-authored-by: Aymeric Rabot <aymeric@pascal.app>
Co-authored-by: Sudhir Yadav <sudhir9297@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Wassim SAMAD <wass08@gmail.com>
Co-authored-by: Aymeric Rabot <aymeric.rabot@gmail.com>
Co-authored-by: Adam NAILI <18304870+AxiomeCG@users.noreply.github.com>
Co-authored-by: ActArtech <123718991+ActArtech@users.noreply.github.com>
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Co-authored-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com>
Co-authored-by: Wu Shuwen <mikewushuwen@outlook.com>
…pascalorg#857)

collectFloorplanSchedules walked the whole level subtree and ignored
FloorplanExportScope, so a structure-only PDF still emitted zone/room
schedule pages whose geometry the same export excluded.

Thread scope into the collector and gate schedule contributors with
isFloorplanNodeInExportScope, matching geometry collection.

Fixes pascalorg#631
* fix(editor): store scans locally without host uploads

* Host hooks for a guided tour: targets, a success cue, snapping and camera hints (#728)

* editor: share the shortcut glyph resolver and note Escape's mid-draw behavior

The Keyboard Shortcuts dialog owned the only key→glyph map (⎋, ⌫, ⌘/Ctrl, …)
while `ShortcutToken` — the thing that actually prints a key — knew only about
the command modifier. Move the map next to the token as `shortcutDisplayValue`
and export both from the package, so a second surface (the community
getting-started guide, which teaches Escape / undo / delete) prints the same
glyph for the same key instead of re-deriving the platform rules.

The dialog's output is unchanged; it drops its own copy of the map and its
`isMac` state in favour of the token file's module-level detection, which it was
already relying on for ⌘ anyway.

Also record what Escape really does in `use-keyboard.ts`: a tool that has an
active mid-action consumes the cancel (`markToolCancelConsumed`), so mid-draw it
drops only the chain and keeps the tool armed — the dialog claimed it always
returns to Select mode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfcEhXQKCFz216c2Y11Wah

* editor: print Esc, Space and Delete as words, and document the modifier taps

`shortcutDisplayValue` printed ⎋ for Escape, ␣ for Space and ⌫ for
Delete. None of those appear on a keyboard, so they read as "some
symbol" rather than as the key they mean — the getting-started guide
tells users to press Esc and rendered a glyph almost nobody decodes.
They now spell their names, and Delete uses the name the current
platform actually puts on the keycap. ⌘ and the arrows stay: those are
printed on the keys they stand for.

Shift keeps its icon in the token (the rail already draws ph:arrow-fat-up
because the ⇧ glyph sits too high in this font), but drops out of the
display map so any surface printing it as text now gets the word.

Also lists the two tap bindings in the Keyboard Shortcuts dialog. Shift
and Ctrl each mean one thing held and another tapped, and only the hold
was documented, which read as the taps not existing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfcEhXQKCFz216c2Y11Wah

* editor: keep in-scene HTML under the viewer overlays, and mark the level +

drei's `<Html>` derives its z-index from camera distance and defaults to a
range topping out at 16,777,271. Nothing between it and the viewer column
created a stacking context, so those values competed directly with the
toolbar (z-20), the stage overlay (z-10) and the overlay band (z-30) — and
the wall tool's cursor badge painted over anything an app put in that band,
including the community getting-started card. `isolate` on the canvas area
confines every in-scene HTML layer inside the canvas and leaves their order
relative to each other untouched.

The `data-guide-target` on the level stack's "add above" button is a static
hook for host-app onboarding to point at, read only from outside. Nothing
here depends on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfcEhXQKCFz216c2Y11Wah

* editor: add a success cue to the SFX registry

A three-second jingle for finishing something, next to the click and thud
cues the registry already holds. No pitch or volume jitter — a fanfare
that lands a semitone off reads as broken rather than as varied — and a
minimum gap longer than the sound itself, so two milestones that land
together play once instead of phasing over each other.

The asset ships with the standalone editor because `preloadSFX` loads
every definition in the registry, wherever the player is mounted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfcEhXQKCFz216c2Y11Wah

* editor: let a host tour point at the Select control

One static `data-guide-target` attribute on the action menu's Select
button, matching the level selector's `level-add`. Nothing in the package
reads it: it exists so a host app's first-run guide can find the control
without the editor knowing a guide exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfcEhXQKCFz216c2Y11Wah

* Two guide targets: the material picker and the ground-floor level row

Both are one static `data-guide-target` attribute and nothing else — no
import, no hook, no conditional class, no knowledge that a guide exists.
The host app's getting-started guide resolves and rings them from outside.

- `paint-material` on the material picker band in `MaterialPaintPanel`, so a
  guide can ask for a brush to be loaded before asking for a surface to be
  clicked. The grid rather than a swatch: the ask is that something is
  chosen, not which.
- `level-ground` on the floating level selector's ordinal-0 row — the one
  level a guide can name without knowing anything about the building. Same
  conditional-attribute shape the Build grid already uses for its tiles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfcEhXQKCFz216c2Y11Wah

* editor: export the active snapping mode

A host that has to say something about snapping needs the same answer the
HUD chip on the right of the screen shows, and the editor already has one
source for it — `getActiveSnappingMode`, resolved through the active snap
context. Exported alongside `resolveSnapFlags` so a host can ask whether
the grid is what is in play rather than re-deriving the mapping and owning
a second version of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfcEhXQKCFz216c2Y11Wah

* editor: let a host narrow the camera controls hint

The panel over the canvas explains Pan, Rotate and Zoom at once, which is
the right answer for somebody left to find their own way and the wrong one
for somebody being asked for a single gesture: three controls on screen
turns "drag to orbit" into a search. And once the lesson is over it is a
permanent widget explaining what the user has just been walked through.

`useCameraHintFocus` narrows it to the actions a host names, and hides it
outright for an empty list. Null — the default, and what every host gets
without touching it — is all of them, unchanged.

A store rather than a prop because the thing that knows which gesture is
being taught is several levels away from the canvas, and threading it
through would put a teaching concern in every component between. The
editor knows only "show these actions"; it never learns why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfcEhXQKCFz216c2Y11Wah

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* editor: let a host tour point at the snapping chips (#730)

One static `data-guide-target` on each of the helper panel's two snapping
chips — `snap-mode` on the Shift/mode chip and `snap-grid-step` on the
Ctrl/grid-step chip — through an optional `guideTarget` prop on `ChipRow`.
Same shape as the Select button, the level "+" and the material picker:
nothing in the package reads it.

The community getting-started guide's window step used to name the keys in
its small print and say the chips were "on the right of the screen", which
first-time users read as decoration. With a hook on the chip the guide can
ring the actual control and ask for the grid step to be shrunk as a beat.


Claude-Session: https://claude.ai/code/session_01EfcEhXQKCFz216c2Y11Wah

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* editor: fix roof and placement previews (#718)

* Add roof surface placement support for items

Items (e.g. solar panels) can now be placed on sloped roof surfaces.
The placement system computes euler rotation from the roof surface
normal so items sit flush on the slope instead of going inside.

- Add roofStrategy to placement-strategies with enter/move/click/leave
- Wire roof:enter/move/click/leave events in the placement coordinator
- Add calculateRoofRotation in placement-math using surface normals
- Support full 3D cursor rotation for sloped surfaces
- Items on roofs are parented to the level with world-space rotation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fixed conflict

* feat: improve roof placement and hosted extensions

* fix: preserve curved lean-to roof connections

* fix roof and dormer editing behavior

* feat: improve roof openings and lean-to canopies

* Fix centered lean-to placement

* Align placement previews with architecture

* Fix dormer window placement grid orientation

* fix roof canopy geometry and continuous placement

* stabilize canopy miter quality assertions

* stabilize canopy gutter quality assertions

* fix conical roof hover elevation across levels

* fix conical roof placement on higher floors

* limit conical wall roofs to adjacent levels

* limit continuous mono canopy mitering to single-corner L runs

Straight freestanding mono runs now only miter across a corner when the
joined chain is exactly two runs (an L). J-shapes, longer chains, and
closed loops render as plain overlapping runs — no shaped footprint,
no joint step closures, no corner extension — which avoids the dark
wedges and fascia slivers those multi-corner miters produced. Curved
and wall-attached canopies keep their multi-corner mitering.

The joint step closure that L runs still use is rebuilt on geometric
ownership (the lower run raises the closure wall to the sibling top),
so it no longer depends on node-id order or draw direction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* restrict conical roofs to curved wall footprints

* fix conical roof placement architecture

* fix remaining Bugbot findings

* restrict standard roofs to rectangular rooms

* ignore curved walls for standard roof drawing

* fix lower-floor roof room preview elevation

* test L-shaped roof footprint eligibility

* ignore diagonal walls for standard roof guides

* fix roof placement architecture and window previews

* fix roof footprint previews and wall guides

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* feat(editor): expose exportFloorplanPdf from the package entry (#636)

* feat(editor): add structure+utility 'routing' floorplan export scope

Add a third FloorplanExportScope value that includes structure and
utility nodes (ducts, pipes, HVAC) without furniture, shared through a
pure isFloorplanNodeInExportScope predicate used at both collection
filter sites. Existing 'full' and 'structure' behavior is unchanged.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>

* feat(editor): export exportFloorplanPdf from the package entry

Re-export exportFloorplanPdf and the FloorplanExportScope type from
@pascal-app/editor so hosts with their own export UI can trigger a
floorplan PDF export without reaching into the settings panel.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>

* fix(review): add package-entry re-export smoke test and drop redundant comment

Per code review, add a consumer-side compile-time assertion in apps/editor
that imports exportFloorplanPdf and FloorplanExportScope from the
@pascal-app/editor package entry, so a broken re-export fails check-types
instead of passing silently. Also drop the WHAT-narration doc comment on
collectFloorplanGeometry (behavior-preserving).

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>

* docs(review): record residual review findings

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>

* refactor(editor): drop routing floorplan export scope

Per review on #636: the 'routing' scope has no UI trigger anywhere
(#632) and its name would collide with PrintContentScope's vocabulary
from #701. Narrows FloorplanExportScope back to 'full' | 'structure'
and simplifies the predicate accordingly. 'full' and 'structure'
behavior is unchanged.

Also renames the predicate's first parameter from `node` to
`definition` (it receives a NodeDefinition, not a node instance) and
corrects the apps/editor smoke test's comment, which mis-described a
runtime barrel import as a compile-time check.

* docs(review): drop residual-findings file, not a supported doc location

This repo keeps durable docs in wiki/, not a top-level docs/ tree, and
the file cited paths that don't exist here (a /tmp/... run path and a
docs/plans/... plan file). The two facts worth keeping — the tracker
issues filed from the original review round, and that #631 is the one
confirmed-real finding — move into the PR description instead.

---------

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>

* fix(packaging): declare license on @pascal-app/editor, ship LICENSE in tarballs, re-export calculateLevelMiters from core/wall (#732)

- @pascal-app/editor published with no license field; automated license
  inventories reported it as unlicensed. Declare MIT like its siblings.
- npm auto-includes a package-root LICENSE in the tarball, so committing
  the root MIT text into each published package satisfies the MIT
  attribution requirement without touching the files allowlists.
- @pascal-app/core/wall exported getWallPlanFootprint(wall, miterData)
  without any way to produce or even name miterData; re-export
  calculateLevelMiters plus the Point2D/WallMiterData types from the
  same entry. wall-mitering was already a static dependency of the
  entry, so the module graph is unchanged.

Reported in #731.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Carry scene materials through Load Build (#729)

* Carry scene materials through Load Build

validateBuildJson dropped the top-level materials table, so every
scene:<id> slot ref in an imported file pointed at a material that no
longer existed — custom finishes silently reverted to defaults on Load
Build. ParsedBuildJson now carries materials, each entry validated
individually (a bad material never takes the import down, it is
skipped with a warning), and handleConfirmImport hands them to
setScene, whose extra.materials support already existed.

Normalization here is DELIBERATE and documented in-line:
safeParse().data injects defaults and drops unknown keys — the
opposite of apiGraphSchema's preserve-unknowns stance — because import
feeds the live scene store, which only understands schema-shaped
materials.

Split out of #720 at the maintainer's request.

* Save Build exports the materials table it now imports

Review follow-up (#729): paint a finish, Save Build, Load Build that
file — the finish reverted to default because handleSaveBuild still
exported only { nodes, rootNodeIds, installedPlugins }. Materials ride
along now, closing the round-trip this PR opened on the import side.

Also names the skipped ids in the invalid_materials warning: the
audience is hand-edited files, and a bare count leaves nothing to
repair by.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix(mcp): report skipped persistence instead of silently dropping live sync (#736)

publishLiveSceneSnapshot returned void and silently skipped both the
draft save and the live event whenever no active scene was bound or the
store could not append scene events — mutations 'vanished' with no
error, no log, and no signal to the caller, sending people hunting in
the wrong layer (#725).

Neither of the two fixes #561 debated survives the constraints: a typed
throw breaks the plain --stdio quick start on the first create_wall, and
lazily binding a draft scene creates persistent artifacts the user never
asked for on stores that may require project context. Instead the skip
becomes visible at the layer the caller sees: publishLiveSceneSnapshot
returns 'published' | 'unbound' | 'events_unsupported', and every
mutating tool spreads a persistence warning into its result (declared in
the tool outputSchema via a shared fragment so the SDK's structured-
content validation keeps it). An AI caller can react by binding a scene
with save_scene/load_scene; a human reading the transcript sees why
nothing persisted.

InMemorySceneStore now implements appendSceneEvent/listSceneEvents so
the three paths are testable; live-sync.test.ts covers them through a
real client/server pair and at the unit level.

Fixes #725

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* nodes: improve modular cabinet constraints and finishes (#719)

* Add roof surface placement support for items

Items (e.g. solar panels) can now be placed on sloped roof surfaces.
The placement system computes euler rotation from the roof surface
normal so items sit flush on the slope instead of going inside.

- Add roofStrategy to placement-strategies with enter/move/click/leave
- Wire roof:enter/move/click/leave events in the placement coordinator
- Add calculateRoofRotation in placement-math using surface normals
- Support full 3D cursor rotation for sloped surfaces
- Items on roofs are parented to the level with world-space rotation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fixed conflict

* feat(cabinets): improve modular sizing and ceiling finishes

* fix(cabinet): improve constrained run reflow

* fix(nodes): constrain modular cabinet reflow

* chore(cabinet): format top finish changes

* fix modular cabinet appliance and corner behavior

* fix cabinet schema version

* fix(cabinet): improve placement snapping behavior

* fix cabinet wall snapping and direct drag

* fix cabinet resize handle pointer ownership

* fix cabinet resize constraints and reflow

* fix cabinet snap architecture findings

* fix cabinet corner resize edge cases

* fix cabinet reflow and handle hit priority

* fix occluded drag and snapped rotation

* fix nested wall cabinet panel edits

* fix L reflow context and run height bounds

* fix L-leg panel edit ownership

* chore: remove cabinet planning artifacts

* refactor: move roof placement into node registry

* fix cabinet resize handle pointer ownership

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* fix(editor): correct wall editing and roof controls (#738)

* fix wall curve topology and height drag dimensions

* fix roof accessory paint slots and draw default

* fix duplicate roof segment drainage control

* fix wall editing and add thickness handles

* add plain-language OpenPR2 skill

* viewer: fix lean-to roof mitering across connected runs (#739)

* Add roof surface placement support for items

Items (e.g. solar panels) can now be placed on sloped roof surfaces.
The placement system computes euler rotation from the roof surface
normal so items sit flush on the slope instead of going inside.

- Add roofStrategy to placement-strategies with enter/move/click/leave
- Wire roof:enter/move/click/leave events in the placement coordinator
- Add calculateRoofRotation in placement-math using surface normals
- Support full 3D cursor rotation for sloped surfaces
- Items on roofs are parented to the level with world-space rotation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fixed conflict

* fix lean-to roof miters across connected runs

* fix canopy miter edge cases

* keep continuous canopy endpoints connected

* avoid invalid curved gutter miters

* fix(viewer): keep curved shed miters flush

* fix lean-to roof mitering and gutter spans

* test(nodes): allow canopy angle sweep under CI load

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* editor: don't put an empty plugin manager in an empty rail (#741)

The open lobby (`/play/<id>`) mounts the editor under a read-only lease and
registers no host panels, so the plugin *manager* was the only tab in the
rail — and the rail opens on its first tab. A visitor who left the game found a
bare "Plugins" heading covering ~40% of the window over the world they had come
to play in.

Gate the manager on having something to manage, or on a writable scene:

- `managedPluginIds(panels)` — the distinct plugins behind the registered
  panels, since one plugin may contribute several and the manager lists plugins.
- `showsPluginManager({managedPluginCount, readOnly, workspaceMode})` — the tab
  earns its slot when a plugin is registered, or when the scene is writable and
  "Create a Pascal plugin" is still worth offering.

The only case this drops is read-only *and* nothing registered, which is exactly
the lobby; with no tabs at all both the v2 and mobile layouts already skip the
sidebar entirely, leaving the canvas alone. A read-only editor keeps the tab as
soon as a plugin is registered — browsing what a project uses is a read, and the
install button was already disabled on its own. Writable edit-workspace
behaviour is unchanged, and fenced by a test that says so.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* editor: capture-mode camera suite — FOV lens, walk/drone framing, keycap hints (#742)

* feat(editor): capture-mode fov control and orbit/walk/fly framing cameras

- Lens pill in the snapshot overlay drives the main camera's fov (15-110°)
  while capture is open; entry fov restored on every exit path
- Orbit / Walk / Fly segmented control: walk reuses first-person, fly is a
  new no-gravity/no-collision variant on the same controls
- Enter fires the shutter (pointer lock makes the button unclickable);
  walk/fly snapshots synthesize a stored target down the view axis
- Walkthrough HUD hidden and fov effect no-oped while capture owns the frame

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gQSsJ7nfdARkNH5PcKUjt

* feat(editor): drone mode polish — rename, E/Q vertical keys, keycap hints

Fly becomes Drone (lucide Drone icon, internal ids renamed; nothing
persists the value). Drone vertical: Space or E up, Q or Ctrl down; walk
keeps E/R door interaction. Camera hints are keycap chips instead of
sentence lines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gQSsJ7nfdARkNH5PcKUjt

* fix(editor): drop backdrop-blur from snapshot capture overlay pills

backdrop-filter over the WebGPU canvas flickers black on ProMotion/XDR
displays; the pills are near-opaque so the blur read as solid anyway.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gQSsJ7nfdARkNH5PcKUjt

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* editor: keep walk/drone snapshot framing as clean as orbit's (#743)

Snapshot capture in walk/drone still ran the viewer's default selection
manager (cursor hover highlights) and ViewerZoneSystem showed zone
geometry and tags — none of which orbit capture allows into the shot.
Gate both on capture mode.


Claude-Session: https://claude.ai/code/session_018gQSsJ7nfdARkNH5PcKUjt

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* deps: cap zod below 4.5.0 (#744)

Zod 4.5.0-4.5.4 (upstream PR #6432) makes a wrapped discriminator claim
undefined in addition to its literal. All 48 AnyNode members use
z.literal(t).default(t) as the discriminator, so the union's lazily
built map throws 'Duplicate discriminator value "undefined"' at first
parse — a plain Error that escapes safeParse. Cap every zod range below
4.5.0 until the projection fix lands, and drop the unused zod dependency
from apps/ifc-converter.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* core: project AnyNode discriminators to bare literals (#745)

`nodeType()` defaults the node `type` literal so a per-kind schema can fill
it in (`WallNode.parse({ start, end })`). From zod 4.5.0 (upstream #6432) a
`.default()`-wrapped discriminator additionally claims `undefined`, so all 48
`AnyNode` members collide on that key: the union's lazily-built discriminator
map throws `Duplicate discriminator value "undefined"` at the first parse, as
a plain Error that escapes `safeParse` — a crash in every path that validates
a node (scene load, API boundary, MCP bridge).

`nodeUnion()` projects each member to a clone whose `type` is the bare literal
before assembling the union, carrying the member's registry metadata across
`.extend()` so `.describe()` text survives. The 48 node files and `nodeType()`
are untouched, so per-kind parsing keeps its default.

The two call sites that read the kind by parsing the defaulted literal
(`AnyNode.options.map(o => o.shape.type.parse(undefined))`) now use an
exported `nodeKindOf(option)`, which also replaces the `_zod.def` walk in the
nodes coverage test.

Verified on the pinned zod 4.4.3 (core 1164, nodes 1830, mcp 341 tests green,
workspace typecheck clean) and smoke-tested on zod 4.5.4, where the schema
tests pass with the projection and an unprojected two-member union still
throws `Duplicate discriminator value "undefined"` out of `safeParse`.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* core: type node metadata as a record instead of z.json() (#746)

`BaseNode.metadata` (and `ZoneNode`'s redeclaration of it) was `z.json()`,
zod's recursive JSON value. That schema is self-referential, and a
self-referential member is exactly what zod 4.5's `z.compile()` refuses to
compile — so it wasn't just the slowest field in every node, it denied the
whole node tree the compiled parser.

Measured on the real `WallNode` (200k parses, steady state):

| schema             | zod 4.4.3 | 4.5.4 interpreted | 4.5.4 `z.compile()` |
|--------------------|-----------|-------------------|---------------------|
| `z.json()`         | 125ms     | 127ms             | 133ms (1.0x)        |
| `z.record(string, unknown)` | 81ms | 68ms          | 31ms (2.2x)         |

So ~1.5x per-node parse today on the shipped zod, and a 4x gap once the
compiled parser lands.

Metadata is a flat bag of per-node extras — every reader in the repo already
guards it with `typeof === 'object' && !Array.isArray()` — so an open object
with unchecked values is the whole contract. This does narrow the published
`@pascal-app/core` input contract: `metadata` was any JSON value and is now
object-only (founder-approved 2026-09-01).

Call sites that leaned on the old looser type:

- `nodes/cabinet/run-ops`: four `?.metadata ?? null` fallbacks become `?? {}`
  (both consumers normalized `null` to `{}` already, so no behavior change).
- `nodes/dormer/csg-geometry`: the virtual roof segment's `metadata: null`
  becomes `{}`.
- comments in `ifc-converter` and the converter app that described the field
  as `z.json()`.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* deps: lift the zod cap to >=4.5.4 <4.6 (#747)

#744 capped zod below 4.5.0 because `AnyNode` threw on first parse; #745
fixed that with bare-literal discriminators. The remaining blocker was 27
failures plus a broken `tsc --build` in packages/mcp, which looked like an
MCP-SDK incompatibility with zod 4.5. It wasn't: it was two zod copies in
the tree.

Bun preserves a transitive resolution that still satisfies its range, so
moving our five workspace ranges to 4.5.4 left
`@modelcontextprotocol/sdk/zod` pinned at 4.4.3 while our schemas ran on
4.5.4. The SDK's compat layer is typed as
`z3.ZodTypeAny | z4.$ZodType` with `z4` imported from its *own* zod
(dist/esm/server/zod-compat.d.ts:1-3), so every `inputSchema` we hand it
was a foreign `$ZodType` — hence the TS2322s — and its `safeParse` ran
4.4.3 internals over 4.5.4 schema objects, which is where
`expected "nonoptional"` came from. Deleting the nested copy alone (SDK
still 1.29.0) took packages/mcp from 27 failures to 1 and made
`tsc --build` clean.

Bumping the SDK 1.29.0 -> 1.30.0 is the durable form of that fix: it makes
bun re-resolve the SDK subtree, which dedupes zod onto the single root
4.5.4 and drops the nested entry from the lockfile. A from-scratch install
of either SDK version already dedupes; only our incremental range change
skewed. No SDK API changed.

The one genuine zod 4.5 behavior change is in JSON Schema emission: a
union of primitive types now folds to `type: ['number','string']` instead
of `anyOf: [{type:'number'},{type:'string'}]`. Both forms validate
identically under the ajv draft-2020-12 validator the SDK ships (checked
against 0.9, "6 ft", true, null, {}), so `measurement.test.ts` now asserts
the accepted type set rather than the spelling.

Left alone: `ultracite/zod` and `react-doctor/eslint-plugin-react-hooks/zod`
keep their own 4.4.3 — dev-only CLIs that never see our schemas.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* core: lazy per-kind compiled node parsers behind a flag (#748)

`z.compile()` (zod 4.5) AOT-codegens a parser. Applied per node kind, on
first parse of that kind, it is p50 2.8x faster than the interpreted
per-kind schema and 3.4x faster than parsing through `AnyNode`.

Applied to the *union* it is the wrong trade: 41ms of codegen in one hit
and no faster than the per-kind map on mixed input (0.98x). Applied to
all 48 kinds eagerly it costs +44MB RSS. So: one lazy `Map`, one clone
per kind, only for kinds a call site actually sees, and off by default.

Wired at three long-lived call sites — the MCP bridge create path,
scene-load migrations, and the store create/update parses — each of
which re-parses the same handful of kinds for the lifetime of the tab.

Parity is asserted for all 48 kinds: identical output (key set *and*
key order, since `wall.height` absence is a mode), identical issues,
identical `error.message`, both against the interpreted per-kind schema
and against the union. Under `jitless` / a blocked `Function`
constructor, `z.core.util.allowsEval` short-circuits and every schema
stays interpreted — proven in a child process.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* viewer: re-resolve dangling library material refs; paint picker defaults to Pascal (#749)

* viewer: re-resolve dangling library material refs when the dynamic library registers

AI-generated `library:mtl_*` presets register asynchronously (a host fetch),
so a wall that renders before they land resolves its painted slots to the
slot default — and the wall material cache, whose signature assumed library
refs are static catalog content, pinned that default for the whole session.

Library-ref signatures now carry an `#unresolved` tag while the ref dangles,
and the wall renderer + cutout loop watch the dynamic library version, so a
late registration flips the signature and recomputes the materials.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gQSsJ7nfdARkNH5PcKUjt

* editor: drop the paint picker's All source tab, default to Pascal

Parity with the Items / Rooms / Build browse surfaces, which dropped the
combined list because it buried the curated set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gQSsJ7nfdARkNH5PcKUjt

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* registry: declarative def.toolOptions + shared ToolOptionsPanel; roof draws from it (#750)

The roof's 'Create from' (draw two corners vs pick a detected room) only
existed as a hand-rolled row in the standalone Build tab, backed by
toolDefaults.roof.footprintSource — which preset seeding nulls on every
activation and the roof tool clears on unmount, so the choice never
reliably survived, and the community Build sidebar (preset-driven,
hardcoded) never surfaced it at all.

Kinds now declare pick-one option rows via def.toolOptions (the sidebar
sibling of toolHints[].chip), rendered by the shared <ToolOptionsPanel>
that any host mounts once — no per-kind host wiring. The roof declares
footprintSource over a small ephemeral store (like roof-placement-mode),
the tool and the 2D floorplan hook read that store (the hook through the
registry — its editor sources land in the nodes program, where a nodes
import would cycle onto nodes' own dist), and the standalone Build tab
replaces its hand-rolled row with the panel.


Claude-Session: https://claude.ai/code/session_018gQSsJ7nfdARkNH5PcKUjt

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* editor: single-writer ToolMode FSM for mode/tool (#751)

mode and tool were two independently-writable, independently-persisted
fields with six ad-hoc writers (setMode, raw setTool, setPhase's rewrite,
setStructureLayer's rewrite, keyboard, panels), so impossible states —
build with no tool, a lit tool in select mode, a paint swatch click that
never re-armed paint mode — were representable and survived reload.

The store now holds a ToolMode discriminated union as the source of truth
(build carries its tool by construction), with armToolMode as the sole
transition: it owns phase/viewMode promotions, default-tool election,
paint priming, and syncBrushModeScope, and materializes mode/tool mirrors
so the ~150 existing readers are untouched. armMaterialPaint arms paint
and sets the brush in one step — the material picker now routes through
it, so picking a swatch always returns to paint mode. setMode/setTool
remain as thin wrappers over the transition; rehydration normalizes the
persisted toolMode against legacy mode/tool pairs.


Claude-Session: https://claude.ai/code/session_018gQSsJ7nfdARkNH5PcKUjt

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* editor: level-follow camera, snapshot walk/drone suite, opening placement regressions (#752)

* editor: camera follows the level across mode switches and new levels

Switching level presentation (stacked/exploded/solo) never moved the
camera — the level-frame effect only fired on selection change — and a
freshly created level framed at y=0 because the effect read the level
Object3D's position before LevelSystem had lerped it anywhere.

The effect now derives the destination analytically (stacked elevation +
exploded gap, shared with LevelSystem via getLevelPresentationY), watches
levelMode, and skips when already on target — which also swallows the
thumbnail generator's synchronous stacked/restore round-trip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gQSsJ7nfdARkNH5PcKUjt

* editor: studio snapshot camera polish — capture pill, instant pointer lock, wheel lens + click shutter

- The Studio capbar's preselected crop no longer hides the
  standard/viewport/area pill: preselecting seeds the overlay, and only an
  explicit host lockCrop (the publish cover's exact-shape capture) hides
  the switcher.
- Switching the snapshot camera to walk/drone locks the pointer in the same
  click (flushSync mounts the controls first) instead of demanding a second
  canvas click.
- While walk/drone hold the lock: wheel drives the lens (accumulated
  sub-degree deltas, wheel-up zooms in) and left click fires the shutter
  alongside Enter. Walk's door-toggle click is silenced during capture, and
  the acquiring click can't shoot (shutter gates on the lock being held).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gQSsJ7nfdARkNH5PcKUjt

* editor: fix window on-wall placement preview and opening cursor facing

Two regressions in opening placement:

- #718 rewrote MoveWindowTool to publish drag state through
  useLiveNodeOverrides, including `parentId` — but reparenting is
  structural: the wall's CSG merge and the renderer's nesting walk the
  wall's `children` array, which an override never joins. Placing a window
  preset showed no on-wall preview at all (no cut, no mesh — only the
  override-independent guides), while doors, still on scene writes, worked.
  The wall branch and free-follow now write the scene exactly like
  MoveDoorTool (reparent on host change, direct mesh transform + live
  transforms on same-host slides), and stale overrides are dropped when
  entering the wall mode.

- The door/window PLACEMENT tools still fed `calculateCursorRotation` into
  the cursor and facing triangle — the helper #643 identified as π off and
  migrated every other caller away from. The triangle pointed at the far
  side of the wall on half the walls. Both tools now use the wall-child
  world yaw (`itemRotation - wallAngle`, the move tools' convention), and
  the helper is deleted so nothing can regress onto it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gQSsJ7nfdARkNH5PcKUjt

* editor: capture walk/drone — E opens, Esc pauses, click shoots, drone re-locks

Four snapshot-camera fixes:

- E/R open doors and windows again during capture walk (only the CLICK
  path is capture-gated now — a locked click is the shutter), and the
  walkthrough crosshair (dot → green ring over an interactable) renders in
  the capture overlay, which replaces the walkthrough HUD.
- Esc acts like P in walk/drone: the browser's pointer-lock exit pauses
  (cursor freed, camera and capture kept) instead of bailing to orbit and
  throwing away the framed pose; the overlay only dismisses on Esc from
  orbit. Covers both the keydown path and the no-keydown native unlock.
- The click shutter actually fires: FirstPersonControls' document-capture
  mousedown handler stops propagation while locked, so the overlay's
  listener moves to window-capture (and the door-toggle mousedown yields
  during capture).
- Switching cameras right after freeing the cursor hit the browser's
  ~1.25s re-lock cooldown — the reason drone (only reachable with a free
  cursor) never locked while walk-from-orbit did. The lock helper retries
  once after the cooldown while still framing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gQSsJ7nfdARkNH5PcKUjt

* editor: freeze walk/drone while the shutter renders

From the click/Enter until the saved toast clears, look, walk physics and
drone motion hold still — a late WASD tap or mouse twitch no longer shifts
the frame out from under the shot the user just took.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gQSsJ7nfdARkNH5PcKUjt

* editor: second Esc in capture walk/drone cancels the snapshot

First Esc frees the cursor (pause); with the cursor already free, Esc now
cancels capture — setCaptureMode(false) lands the camera back on orbit —
instead of doing nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gQSsJ7nfdARkNH5PcKUjt

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* feat(cabinet): support exact sizing and run-aware editing (#753)

* Add roof surface placement support for items

Items (e.g. solar panels) can now be placed on sloped roof surfaces.
The placement system computes euler rotation from the roof surface
normal so items sit flush on the slope instead of going inside.

- Add roofStrategy to placement-strategies with enter/move/click/leave
- Wire roof:enter/move/click/leave events in the placement coordinator
- Add calculateRoofRotation in placement-math using surface normals
- Support full 3D cursor rotation for sloped surfaces
- Items on roofs are parented to the level with world-space rotation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fixed conflict

* Enhance modular cabinet editing and validation

* Complete cabinet interaction enhancements

* Fix architecture review findings

* fix cabinet wall opening awareness during moves

* feat cabinet exact dimension placement

* feat cabinet run width equalization

* feat cabinet run array duplication

* feat add wall cabinet height presets

* chore remove swing check and hinge quick action

* fix cabinet preview architecture findings

* fix(cabinet): preview linked L runs during width resize

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* ?perf instrumentation suite: real GPU timestamps, system tracks, action receipts, DOM panel (#755)

* viewer: perf-tracks shared sink for ?perf instrumentation

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nq2rLE18tFVES2LU6HGokK

* viewer/editor: ?perf instrumentation pass — real GPU timestamps, per-system User Timing tracks

- DRAW read info.render.calls (lifetime render() count, never reset) — now drawCalls
- GPU time now from WebGPU timestamp queries (trackTimestamp + resolveTimestampsAsync);
  the old queue-fence delta stays as QUEUE (backpressure), encode CPU as ENCODE
- perf-tracks: shared sink emitting DevTools custom tracks (trackGroup Pascal) +
  per-window counter buckets; perf-observers: longtask observer
- spans: frame-cpu (FrameLimiter advance), geometry builders, wall miter/rebuild/CSG,
  door/window rebuilds, pointer raycast, react-render Profiler boundary
- panel: FRAME cpu avg/max, MEM (info.memory + JS heap), visible-only census at 2s,
  TRACKS readout, clearPerfMeasures per drain; FPS threshold matches the 50fps cap
- deleted dormant DebugRenderer (unreferenced; would double-render if mounted)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nq2rLE18tFVES2LU6HGokK

* viewer: perf panel as draggable DOM tool, out of the canvas

drei <Html> wrappers carry a camera-driven transform, which turns the old
overlay's position:fixed into 'fixed relative to the wrapper' — the panel
drifted with the camera. PerfMonitor is now a headless in-canvas collector
publishing to perf-panel-store; PerfPanel portals to <body>: draggable by
header, dockable to the nearest edge as a live fps tab, placement persisted
in localStorage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nq2rLE18tFVES2LU6HGokK

* viewer: perf-actions ledger contract + sample tap in perf-tracks

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nq2rLE18tFVES2LU6HGokK

* viewer/editor: action-cost ledger for ?perf

Edit gestures now produce receipts: begin/commit/cancel bracket a gesture,
every perf-tracks sample in between is attributed to it, and the action
settles only when dirty nodes + deferred wall rebuilds hit zero and one more
GPU sample lands. Receipts surface in the panel (last action + breakdown),
the console, and a DevTools 'Actions' lane.

Call sites: the interaction scope store as the generic bracket (yields to
more specific ones), use-drag-action, 2D floorplan gestures, place/undo/
redo/delete/level-switch; markToolCancelConsumed finalizes cancels.
Settle system feeds the ledger per frame at priority 100.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nq2rLE18tFVES2LU6HGokK

* viewer: window.__pascalPerf probe hooks for scripted perf runs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nq2rLE18tFVES2LU6HGokK

* viewer: settle counts only live dirty nodes

A node deleted while dirty (undo of a wall split) leaves its mark in
dirtyNodes forever — no system clears marks for missing nodes — and that
phantom dirt kept every action receipt from settling in furnished scenes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nq2rLE18tFVES2LU6HGokK

* perf ledger: review fixes — scope ownership, settle without timestamps, no-op undo

- the interaction scope now commits only the action IT began (id token), and
  yields only to an UNCOMMITTED action, so a settling receipt can't swallow
  the next gesture and a specific call site's cancel is never committed by
  the generic bracket
- devices without timestamp-query settle on the queue fence instead of
  timing every receipt out
- a no-op undo/redo no longer opens a receipt
- import order (CI quality)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nq2rLE18tFVES2LU6HGokK

* viewer: declare react-dom (portal in perf-panel)

CI typecheck resolves per-package: the perf panel's createPortal import needs
react-dom declared, not inherited from hoisting. Same peer + types pattern as
packages/editor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nq2rLE18tFVES2LU6HGokK

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Add presentation.findInCatalog opt-out for the action menu's find button (#756)

Panel-placed plugin kinds (e.g. pets) have no catalog entry to find; let a
definition drop the Search action while keeping move/duplicate/delete.


Claude-Session: https://claude.ai/code/session_01FB5xnSoozJZ1jXCFgbLSDG

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* capture-viewer: render extracted JSON preview artifacts (#757)

The manifest-first archive moved surface-mesh, point-cloud, and
device-motion payloads out of inline stream data into JSON preview
artifacts, but renderability and hydration still assumed inline —
surface mesh reported "no data", point cloud and the motion trajectory
silently vanished. Accept application/json payload artifacts as
renderable and fetch them into the inline payload shape the layers
already consume.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Dirty-lifecycle correctness: guarded dirty set, undo phantom sweep, animation split (#758)

* perf probe: expose raw dirty-set census as __pascalPerf.dirtyResidue()

The panel's DIRTY readout filters to live nodes, so scripted matrix runs
could not see phantom marks (deleted-node ids) or distinguish stuck kinds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nq2rLE18tFVES2LU6HGokK

* Dirty-lifecycle correctness: guarded dirty set, undo phantom sweep, animation split

Three stuck-mark classes kept scenes from ever settling to DIRTY 0
(charter findings 7/8):

- ~120 call sites add to dirtyNodes directly, bypassing markDirty's
  consumer-kind guard — a wall's parentId is a level, so unconsumable
  level/building marks wedged forever. dirtyNodes is now a GuardedDirtySet
  whose add() applies the guard itself.
- Undo/redo rewrites nodes without the delete actions, leaving marks for
  nodes that no longer exist (rich-2x: 47 phantoms after one scripted
  run). The temporal subscriber now sweeps marks whose node is gone.
- Door/window animation systems marked dirty every tween tick, so DIRTY 0
  was unreachable while anything animated. A dirty mark is one-shot work:
  DoorSystem rebuilds doors straight off doorAnimations entries, window
  types without a direct pose path use a transient rebuild set, and only
  the settled pose gets a final one-shot mark.

The ?perf settle detector counts the raw set again (its live-only filter
papered over the phantoms it now must catch).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nq2rLE18tFVES2LU6HGokK

* wiki: dirty marks are one-shot work — animations signal via their records, not markDirty per tick

The 'system advances animation then calls markDirty' recipe was the
exact pattern behind charter finding 8; the dirtyTracking section now
describes the GuardedDirtySet enforcement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nq2rLE18tFVES2LU6HGokK

* review skill: scope 'markDirty per tick is fine' to bounded gestures

Unqualified, the aside could wave through an animation loop that marks
dirty every frame — the finding-8 class the GuardedDirtySet cannot
block, since animating kinds are legitimately consumable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nq2rLE18tFVES2LU6HGokK

* wiki: same bounded-gesture scoping for tools.md's markDirty aside

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nq2rLE18tFVES2LU6HGokK

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* editor: a failed scene load shows an error with retry instead of an empty scene (#759)

A host `onLoad` that rejects (failed fetch, timeout) used to fall through to
`applySceneGraphToEditor(null)`, i.e. the default site/building/level
scaffold. The autosave loop re-baselined on that cleared store, the next
store touch armed a save, and the scaffold was written over the real
project. Prod audit 2026-09-02: 10–100 such wipes per day; 11–13 % of forks
and template starts opened over two weeks lost their copied scene on the
first save.

Now a failed load keeps the store unloaded and the autosave loop in its
loading state (nothing can be written), and renders `SceneLoadFailed` with
a retry that re-runs the load effect.


Claude-Session: https://claude.ai/code/session_01DLB54VYmGTzvyHNFb3wWxE

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* Node batching: items, columns, doors and windows draw through BatchedMesh containers (#760)

* perf probe: drawComposition() — per-item/per-asset/per-kind draw census

Feeds charter backlog #3: projected draws for per-item merge vs
per-asset instancing, plus a meshes-by-kind bucket for the non-item
side of the budget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nq2rLE18tFVES2LU6HGokK

* spike: BatchedMesh through the WebGPU pipeline (?spike=batch)

400-4,900 instances, two geometries, one material, shadows on. Verdict:
renders/shadows/post-FX clean; ~0.3us encode per instance (~10x cheaper
than a real mesh); per-instance frustum culling active (draw/tri track
the camera). WebGPU counts each multi-draw segment in info.render, so
the 3a metric is encode/frame-cpu ms, not draw calls.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nq2rLE18tFVES2LU6HGokK

* Item draw batching v1: per-material BatchedMesh with wall-batch semantics (charter 3a)

Level-parented items render through one BatchedMesh per (level, material,
attribute-signature) — membership follows the dirty signal, lit items
release and draw themselves (outline/selection paint untouched), joins
wait for a quiet window, isolation stands the whole thing down, and
'thumbnail:before-capture' hands every item its meshes back so exports
never see a batch (belt: batches carry pascalExport='strip').

The container is incremental — instance add/delete per membership
change, geometry deduped per batch, capacity grown 2x on overflow. An
existing batch always accepts a rejoining item; only new batches need
MIN_BATCH_ENTRIES. v1 scope: items whose parent is a level; hosted
items (wall/ceiling/roof) move on host edits that never dirty the item,
so they keep drawing themselves. Interactive/animated/transparent items
excluded by the same rule as walls.

Rich 2x: 114 of 270 items batched (304 instances, 37 batches), idle
frame cpu 10.65 -> ~8.3ms. Full matrix + gates follow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nq2rLE18tFVES2LU6HGokK

* item batch: prune batches orphaned by level-subtree remounts + probe surface

A React remount of a level subtree (thumbnail capture level shuffling,
tool-state swings) replaces the registry groups: imperatively-parented
batch meshes die with the old group while fresh source clones mount with
no layer hold. pruneDetached() spots the orphaned batches by parent
identity each frame and re-stales their items. ?perf probe gains
stats/wave/census/batchRender hooks for scripted verification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nq2rLE18tFVES2LU6HGokK

* ?perf: draw row shows batched instance share

On WebGPU each batched instance still counts once in drawCalls (the
backend loops drawIndexed per visible instance), so a batched scene
looked no cheaper by the panel's DRAW number. The row now reads e.g.
'581 (272 batched · 2 mesh)' — live per sample, so per-instance frustum
culling is visible as the camera moves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nq2rLE18tFVES2LU6HGokK

* ?perf: batch row shows membership truth, not per-pass culling

The first cut read _multiDrawCount, which snapshots whichever camera
culled the batch last — shadow vs main vs outline passes made the
number flip (206 -> 11 on hover) and read as items dropping out of the
batch. The batch system now publishes its membership (items/instances/
containers) to the panel store; the row is stable and only moves when
membership actually changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nq2rLE18tFVES2LU6HGokK

* Node batching 3b: doors, windows and columns join the batch (charter 3a+3b)

item-batch generalizes to shared/node-batch: same wall-batch semantics,
BatchedMesh containers per (level, material, attribute-signature), now
covering items, columns and wall-hosted openings (resolved to the host
wall's level). New release triggers: a dirty WALL cascades to its
openings (the wall edit moves them without marking them), a host wall
that is lit or mid-gesture releases its openings, and a door/window
whose animation record appears draws itself for the tween. Hitboxes
(material.visible=false) and glass (transparent) never batch. Bake
pages (?disable=draw) stand batching down entirely, and the release-
everything paths sweep every level subtree for stale 'batched' holds —
a system can rebuild a batched node's meshes and orphan the tracked
refs, and a stale hold is exactly what the GLB exporter would prune.

Rich 2x: 188 nodes / 1,108 instances batched, in-page A/B frame
9.1 -> 7.4ms (-19%); rich 4x: 2,280 instances, 19.3 -> 14.1ms (-27%).
Gates: e2e 8/8; bake tri+byte parity batching-on vs off; in-session
pixel diff 0.4% confined to seam AA and transparent blend order.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nq2rLE18tFVES2LU6HGokK

* lint: sort node-batch imports/exports, format store

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nq2rLE18tFVES2LU6HGokK

* node batch: id-diff membership + per-batch geometry refcounts (Bugbot findings)

A same-size add-and-remove slipped past the count tell and left the
removed node's instances drawing as ghosts — membership now diffs ids
against last frame's registry. Released geometries drop their packed
mapping at refcount zero, so a rejoin re-packs current vertex content
(a rebuilt geometry can reuse its uuid) instead of the copy captured at
first join; the orphaned range stays until the existing overflow
rebuild reclaims it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nq2rLE18tFVES2LU6HGokK

* node batch: content-stamped geometry reuse + container culling off (Bugbot round 2)

The refcount fix traded one problem for another: dropping the packed
mapping at zero made every hover release/rejoin cycle re-pack the same
geometry, inflating used until premature overflow rebuilds. The mapping
now survives cycles and carries a content stamp (position version +
counts) — reuse is free, and a geometry rebuilt in place under the same
uuid re-packs instead of instancing stale vertices. Whole-container
frustumCulled goes off: its bounding sphere is computed at first cull,
so instances joining farther out later could vanish with the whole
batch — per-instance culling already owns visibility.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nq2rLE18tFVES2LU6HGokK

* node batch: hosted child nodes' subtrees stay out of the host's candidate walk (Bugbot round 3)

An item can host other items (a shelf's books) whose registered groups
mount inside the host's group — the walk packed those meshes as the
HOST's instances, freezing the child at join pose with no release of
its own. The walk now cuts at every hosted child's registered group;
hosted nodes keep drawing themselves (they are outside v1 batch scope
by the level-parent rule anyway).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nq2rLE18tFVES2LU6HGokK

* node batch: hidden-wall openings, autoplaying clips, late-copy gathering (Bugbot round 4)

- A hidden wall hides its openings through group visibility; batch
  instances hang off the level root and kept drawing them. Openings of
  an invisible wall are no longer candidates (the wall's visibility
  dirty-mark cascades the release).
- ItemAnimation autoplays a GLB's first clip even without an
  interactive effect; such items batched and froze mid-motion. The
  renderer stamps clip presence on the registry group and candidates
  exclude it.
- Below-threshold candidates were dropped after their wave, so copies
  placed more than a settle window apart never gathered into a batch.
  A new id now re-stales every unbatched node, letting the copy that
  crosses MIN_BATCH_ENTRIES pull earlier ones in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nq2rLE18tFVES2LU6HGokK

* node batch: partial members re-offer leftovers on new placements (Bugbot round 5) + import sort

store.has locked a partially-joined node (some meshes under the
new-batch threshold) out of every later wave. Partial members are now
tracked; a new placement releases them for a full re-collect, so the
copy that makes a leftover bucket viable pulls them in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nq2rLE18tFVES2LU6HGokK

* lint: format node-batch system

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nq2rLE18tFVES2LU6HGokK

* node batch: overrides release + deferred-peer gathering (Bugbot round 6)

Live overrides now release an already-batched node (a collaborator's
remote drag or a programmatic move carries no local selection to tint
it) and defer, rather than drop, a stale one — the commit mark
re-offers it. Threshold-short candidates land in a leftover set that
re-offers as a group when a NEW leftover arrives, so a copy that was
deferred (selected, dirty, loading) while its peers' wave ran can still
gather them into a batch; a stable leftover set re-offers nothing and
small scenes stay quiet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nq2rLE18tFVES2LU6HGokK

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Wall batching stays live in cutaway (#761)

* Wall batching stays live in cutaway

Cutaway never changes wall geometry: WallCutout swaps each wall's material
array by camera facing and stamps userData.wallHidden. The merged batch
already released tinted walls per frame, so hidden walls take the same
path — released the frame the stamp appears (WallCutout runs at priority
0, this system at 5), excluded from candidates and from the re-merge drift
count, re-sewn at settle when they come back. Mode flips between up and
cutaway no longer dispose every level; down, translucent and isolation
still stand the batch down.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jmmz2AMwTzcnKsSHHPEMhN

* Wall batch: expose membership to ?perf probes

The panel's batch row covers node batching only; scripted runs had no way
to tell whether the merged wall batch was live in a given wall mode.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jmmz2AMwTzcnKsSHHPEMhN

* Wall batch: re-sew a level when its cutaway-hidden walls come back

Releasing on the hidden stamp covered only half the flip: when the stamp
lifted nothing marked the level stale, so a wall that became visible again
drew itself until an unrelated edit re-sewed the floor. Bugbot caught it;
the parity probe confirmed it (rich-4× stayed at 172 batched walls after
returning to full height, now 352).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jmmz2AMwTzcnKsSHHPEMhN

* Wall batch: keep the remembered hidden set apart from the exclusion set

They aliased the same Set, so tinted walls leaked into last frame's hidden
set and read as stamp lifts every frame — the settle window never closed
while anything was selected or hovered. Bugbot caught it. The regression
test hovers one wall after a flip and expects the other eight to re-sew.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jmmz2AMwTzcnKsSHHPEMhN

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* Wall load fixes: treatment fan-out, one CSG cut per wall, capture draw leak, converter duplicates (#762)

* Wall treatments: recompute level miters only when their inputs change, subscribe per wall

While any wall on a level was dirty, the treatment system recomputed the
level's miters every frame (uncached, once per proud offset) and wrote a
fresh object to its store; every wall renderer on the level subscribed to
that object, so all of them re-rendered each frame — 65k WallTreatments
renders in 12 s on the IFC castle, and the same fan-out on every wall drag.

Now the system skips a level whose effective walls (identity, with live
overrides cached per override) and proud offsets are unchanged, caches the
per-proud miter data on those inputs, and each wall subscribes to a slice
holding only its own endpoint intersections, compared structurally. Walls
with no trim enabled mount no treatment work at all.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jmmz2AMwTzcnKsSHHPEMhN

* Wall openings: union the cutouts and subtract once; give heavy walls their own frame

Each opening was subtracted in turn from a result that grew with every
cut, so a wall with 20 openings paid 20 passes over an ever-larger mesh
(70 ms in one call on the IFC castle). The cutouts are now grouped by
bounding-box overlap: disjoint ones are merged as-is, overlapping groups go
through a real union, and the wall is cut once. The progressive rebuild
loop also defers a wall with six or more cutouts when the frame has already
rebuilt something, instead of checking the time budget only between walls.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jmmz2AMwTzcnKsSHHPEMhN

* Wall batch: keep its holds out of the node batch's capture sweep

The project thumbnail autosave fires 30–40 s after load and emits
thumbnail:before-capture. The node batch answers by revealing every mesh
held under the shared 'batched' reason — including the wall sources the
merged wall batch was already drawing — and nothing re-hid them: every
batched wall drew twice (and cast shadows twice) for the rest of the
session. Measured on rich-4×: 4 494 → 5 662 draws, 11.8 → 17.1 ms, forever.

Wall holds now carry their own reason, and the wall batch handles capture
itself: sources come back for the capture (exports still prune off-layer
meshes) and go under again on thumbnail:after-capture.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jmmz2AMwTzcnKsSHHPEMhN

* Wall openings: drop coincident box cutouts, cap the union at four

A real IFC import carries the same door eight times on one wall; coincident
boxes are the worst input a boolean can get, and a union chain over them
took 148 ms and then froze the page. Box cutouts fully inside another box
are dropped before grouping, overlapping groups of up to four are unioned,
and larger groups fall back to the bounded chained subtraction.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jmmz2AMwTzcnKsSHHPEMhN

* IFC converter: emit each door and window once

Every void relationship for a wall appended to its opening list, and the
emission loop created a fresh node per visit, so a fill referenced by
repeated relationships became eight identical doors on one wall (the
sample castle). A fill belongs to one opening, which voids one host, so
emitted fill ids are now tracked globally; spatial children use sets.
Existing converted scenes keep their duplicates until reconverted.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jmmz2AMwTzcnKsSHHPEMhN

* Wall placeholder sweep: stamp rebuilt geometry so degenerate walls stay built

A zero-length or fully cut wall rebuilds to as few vertices as the mount
placeholder, and the sweep re-marked it every 30 frames — 21 castle walls
rebuilt forever and their level's batch re-sewed with them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jmmz2AMwTzcnKsSHHPEMhN

* Sort imports in the wall batch test

Co-Authored-By: Claude F…
…calorg#823)

* fix(editor): honor millimeter notation across measurement panels

Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com>

* feat(skills): add human-openable fit prechecks (#824)

* docs(skills): record agent report release evidence (#825)

* fix(editor): use display precision for level height badges

Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com>

* perf: scope undo/redo invalidation to changed geometry and cleared previews (charter row 7) (#805)

* Scope undo invalidation to changed geometry and cleared previews

* Reset editor state before randomized store tests

* Resolve history probe mocks from each consuming package

* Restore discarded preview dependency closures on undo and redo

* Limit rendered slab invalidation to changed boundary bands

* Cover history support transfers and scoped endpoint rebuilds

* Pin endpoint history closure with spatial sync mounted

* Run package tests against core source without rebuilding dist

* test: drop the repo-wide core source preload

* test: verify consecutive undo and redo invalidation

Zundo 2.3.0 appends the just-left snapshot to both destination stacks, so the existing pre-jump length indices are correct. Cover three adjacency-changing moves and each undo/redo with cleared marks and flushed microtasks.

* fix: invalidate old slab covering dependents on reparent

Refresh covering dependents below both parent levels, deduplicating equal resolved levels. Cover reparent from level 2 to level 3 and undo with exact wall/ceiling sets and unrelated levels left clean.

* perf: drain initial wall builds within the time budget (charter row 6) (#800)

* perf: drain initial wall builds within the time budget

* fix(core): invalidate hydration atomically with scene edits

* test: isolate scene fixtures from randomized ordering

* fix(core): complete normalization before publishing hydration

* fix(viewer): preserve and bound initial wall drain lifetime

* docs: clarify hydration lifetime and wall drain counters

* Experience fix pass: placement, selection rotation, roof, stairs, capture, Cmd+S, three 0.186 (#807)

* fix(capture): round armed FOV, add Alt slow modifier for the drone camera

armCaptureFov stored the live camera FOV verbatim, so fractional pose FOVs
printed float tails in the HUD and left the reset button enabled. Both
writers now share clampCaptureFov.

Alt holds the drone at 0.2x speed and look sensitivity for fine framing;
Shift stays the boost.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* fix(roof): keep the gable shell base on the wall top

The CSG degeneracy guard enforced its 5 cm minimum by lowering the shell
base, which for wallHeight-0 room roofs put the gable 4 cm inside the
wall and z-fought its faces. Raise the eave instead; mirror the floor in
the opening-placement frame and the shed inset panel.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* feat(editor): Cmd/Ctrl+S saves instead of opening the browser dialog

Capture-phase, always-on listener so the page-save dialog never appears.
Hosts can take the chord over via onSaveShortcut; the default flushes the
autosave through the existing executeSave path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* fix(tools): anchor composite presets at their footprint centre, lift previews to the level

Fresh (absolute) placement mapped the cursor to the node origin, so a
cabinet run landed |bounds.center| away from the pointer. Subtract the
rotated centre and keep it under the cursor across R/T. The registry
mover's box/sphere now ride the target level's stacked Y like the other
placement tools.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* fix(stair): follow the storey height from the elected base

Level-destination stairs returned the full floor-to-floor height even
when a slab lifted their base, so the top overshot the storey plane. The
resolver now subtracts the elected base for both destinations. The panel
exposes Follows storey / Custom rise for level stairs, and the stair tool
and landing toggle seed from the storey instead of a 2.5 m constant.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* fix(item): drop un-hosted items to the floor, draw the placement box on the right storey

The floor-path Y was frozen at drag start (#638), so an item pulled off a
shelf kept the shelf height after reparenting. Read the live grid Y
instead. The cursor group, grid surface and facing pose now add the
level mesh's stacked Y, which the building-local tool group lacks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* fix(selection): keep member rotation when pressing R/T mid-drag

translateGroupPatches dropped the snapshots' yaw after a mid-gesture
rotation, so the layout orbited while every item kept its old facing and
the commit wrote the same. Carry rotation for vec3/scalar participants,
pivot every session on the shared mesh-box centre the idle shortcut
uses, and engage an armed session before rotating.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* chore(deps): three 0.186.0

No removed export is used and every peer range admits r186. Two
adjustments: Renderer.dispose() is async now, so the capability probe
swallows its rejection; and r186's CommonJS entry re-exports the ES
module, which Bun cannot require() while the same process imports three
as ESM. A bun test preload steers fiber/drei/maath/meshline (no exports
map, CJS main) to their module builds, the way bundlers already resolve
them. Types stay on 0.184.1 (0.185 types OOM tsgo).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* test: pre-evaluate three in the bun test preload

Bun's plugin onResolve does not run for static imports, so steering the
R3F packages to their module builds never applied in CI (isolated linker)
and the CJS require("three") kept racing the ESM import. Evaluating the
package's own three copy first makes the later require() a cache hit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* fix(roof): lift the inner cutter and deck with the shell eave

The 5 cm CSG floor lifted only the outer shell, so a flat zero-height
roof would have ended up with a solid cap under the deck. Compute the
lift once and apply it to every volume.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* fix(editor): fall back to the autosave flush when the host does not handle Cmd+S

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* fix(roof): floor every prism at 5 cm instead of lifting by the shell's eave

A shell-derived lift left overhanging deck cutters with a negative eave.
Clamp each volume's top the way main did, just at 5 cm and without the
base sink, so cutters stay level with the shells they carve.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* fix(stair): drop the duplicate geometry Rise control

The rise-mode block already exposes the Rise field in custom mode; the
geometry copy wrote totalRise behind the Follows storey toggle.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* test: import resolveSync explicitly in the three preload

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* test: skip the three preload where the cwd has no three dependency

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* fix(editor): seed placed stairs from the elected base; keep the gesture when R/T cannot engage

The stair tool seeded the flight from the storey height alone, a slab
thickness too tall until syncStairRises caught up; it now subtracts the
drop point's elected base like the resolver. A failed engage() on R/T no
longer tears down the pointer listeners.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* fix(stair): cap the placed rise by the pointed support surface

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* fix: scale the stair ghost to the placed rise; await renderer.dispose() before the WebGL fallback

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* fix(stair): read the placed rise from the preview scene

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* fix(selection): re-fit alignment bounds from the start footprint after each R/T

Rotating the previous axis-aligned fit inflated the anchors every step.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* fix(stair): switching to straight materializes a flight; level labels use the shared display name

A curved stair switched to straight had no stair-segment child and drew
nothing (and vanished on select). The type change now creates a default
flight in the same history step and the viewer falls back to that flight
for already-broken scenes. Stair and elevator panels label levels the way
the level switcher does, and the rise toggle reads Follows level like walls.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* feat(roof): wall-footprint roofs follow their source walls' tops

Room roofs computed their elevation once at creation, so a later custom
wall height left the roof at the storey plane. Roofs now remember their
source walls and a core system re-derives position[1] (highest top,
clamped to the level floor) on wall/slab/level edits, history-paused like
the stair rise sync. Moving the roof by hand detaches it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* fix(stair): a flight height edit pins the parent stair to the new total rise

On a follows-level stair the sync handed the edited height straight back,
so the segment slider did nothing. The edit now also writes totalRise
(the stair becomes Custom rise, as editing Rise on its own panel does).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* fix(roof): follow wall tops below the storey plane

Walls shorter than the level (2.5 m in a 3 m storey) left a gap because
the roof elevation was clamped to its level floor. Follow the wall top.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* feat(roof): follow walls by intent, resolved from the footprint

Replace the source-wall id list with support.kind 'walls': room and
conical roofs are created following, the system resolves the enclosure
under the roof centre on the level below and writes the highest wall top
(unclamped), an explicit Y edit or vertical handle drag flips the roof to
custom, and the panel offers Follows walls / Custom like walls do. No
migration; existing roofs stay custom until the user opts in.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* fix(roof): pick supporting walls by footprint overlap, not closed-room membership

A room missing a wall, or an L-room whose centre falls outside, left the
roof frozen. Walls whose band overlaps a segment footprint on the level
below now count; segment-less roofs keep the point-in-room lookup.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* fix(roof): parent room roofs to the storey above their walls; follow walls on the roof's own level too

Armed on the walls' level, the tool parented the roof to that level and
the follow rule only looked one storey down, so a Floor 1 roof dropped to
the Level 0 wall tops. The roof now goes to the level above the walls
when one exists (top floor keeps it on the walls' level), and the
resolver considers walls on the roof's level and the one below.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* test(editor): resolve history probe modules from packages that depend on them (isolated linker) (#828)

* test(editor): resolve history probe modules from packages that depend on them (isolated linker)

* test(nodes): keep the lean-to canopy angle sweep under the per-test timeout on slow runners

* feat(editor): streamline connected pipe and duct drafting (#827)

* Add roof surface placement support for items

Items (e.g. solar panels) can now be placed on sloped roof surfaces.
The placement system computes euler rotation from the roof surface
normal so items sit flush on the slope instead of going inside.

- Add roofStrategy to placement-strategies with enter/move/click/leave
- Wire roof:enter/move/click/leave events in the placement coordinator
- Add calculateRoofRotation in placement-math using surface normals
- Support full 3D cursor rotation for sloped surfaces
- Items on roofs are parented to the level with world-space rotation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fixed conflict

* fix: pass nodes to lazy inspector panels

* feat: add immersive WebXR editor support

* chore: remove WebXR integration

* chore: remove WebXR support

* chore: checkpoint existing editor work before inline insertion

* docs: track inline insertion implementation steps

* docs: record inline insertion domain contract completion

* feat: add inline pipe fitting insertion and run snapping

- Split pipe runs around inline fittings with preserved connections
- Improve run snapping, marquee selection, and rotation shortcut ownership

* feat: route insertion tools through registry scene context

- Add screen-space projection data for cross-view snapping
- Use registry scene APIs for atomic node changes and selection

* feat: keep run end caps aligned during endpoint moves

- Update mated duct and pipe end caps as endpoints move
- Cache shared handle geometry and materials
- Remove redundant connection and snap labels

* fix: scale run direction feedback geometry

- Preserve ray and arrow dimensions while using unit-sized shared geometry

* fix: resolve architecture review findings

* fix(cli): trim vendored archives from runtime

* fix(nodes): preserve automatic end cap ownership

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Wassim SAMAD <wass08@gmail.com>

* skills: portable mcp.json, channel manifests, validator parity (#829)

* chore(skills): portable mcp.json, channel manifests, validator parity

Add the root mcp.json the Agent Plugins spec fixes for Codex and Cursor
(previously only .mcp.json shipped, so those hosts installed the skills
without the MCP server), a Gemini CLI extension manifest, and repository
and icons on server.json. Make plugin.json the single bundle version
source and assert name, version, description and author parity across
all five descriptors, mcp.json/.mcp.json equality, the Claude marketplace
skill set, the documented OpenAI interface fields, byte-identical
.clawhubignore files, and fragment-aware links across skills/README.md
and VALIDATION.md. Fix the broken anchor to the verified GitHub preview,
the 0.1.7 release-notes version, the Cursor snippets to
${env:PASCAL_API_KEY}, add bun run skills:validate for CI and docs,
drop the version literal from mcp-registry.yml, add the skills.sh badge,
the shell-history caveat, and CHANGELOG entries for the distribution work.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore(skills): add Cursor manifest and plate logo for marketplaces

Directory forms want a 1:1 logo on a background plate, and Cursor's
checklist wants it committed and referenced by relative path. Add the
brand mark on its #171717 plate as assets/pascal-mark-plate.svg and the
byte-identical brand-kit 1024 px PNG, point the OpenAI logo at the plate
SVG (composerIcon keeps the transparent mark), add
.cursor-plugin/plugin.json with Cursor-native fields, list the 1024 icon
on server.json, and assert the Cursor manifest's parity and paths in the
validator.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* skills: make skills/ the Claude plugin root (#832)

* fix(skills): make skills/ the Claude plugin root

The Claude marketplace entry sourced the plugin from the repository root,
so every `/plugin install pascal-agent-skills@pascal` copied the whole
monorepo into the plugin cache and, because that root carries package.json
next to bun.lock, ran `bun install --frozen-lockfile --ignore-scripts`
against it on every install and update (60 s timeout, not disableable). A
fresh install produced a 1.2 GB cache, 1.1 GB of it node_modules, to
deliver two markdown skill bundles.

Point the marketplace entry at ./skills and move the Claude plugin manifest
and the bundled local `pascal mcp connect` configuration into that root; a
plugin cannot reference files above its own root, so both have to live
inside skills/. The manifest lists the bundles explicitly because the
default skills/ scan no longer applies once skills/ is itself the root. A
fresh install is now 196 KB with no node_modules, package.json, or
packages/. skills.sh tree URLs, the Codex and Cursor Agent Plugins layout,
Gemini and ClawHub still read the root plugin.json, root mcp.json, and the
same skills/ tree.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore(skills): list the plugin as Pascal in directories

Directory listings show the display name next to product plugins listed
by brand, so use the brand rather than "Pascal agent skills" across the
Claude, Cursor and OpenAI manifests. The identifier stays
pascal-agent-skills.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(changelog): link the plugin-root fix to #832

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* skills: bundle the hosted Pascal MCP server with a key prompt (#835)

* feat(plugin): add hosted MCP server to the Claude Code plugin

The plugin only bundled the local `pascal mcp connect` stdio server, so a
Claude Code user with a Pascal account had to leave the plugin and run
`claude mcp add` by hand before touching a hosted project or a Capture scan.
Declaring the key as `userConfig.pascal_api_key` lets Claude Code collect it
in the enable-time prompt and substitute it into the `pascal-hosted` server's
Authorization header, so the hosted tools arrive with the skills.

The option is `sensitive` so Claude Code stores the key in the OS keychain
instead of settings.json, and `required: false` so a local-only install still
works with the field left empty.

`${user_config.*}` is a Claude Code substitution, so the hosted server cannot
live in the portable Agent Plugins `mcp.json` that Codex and Cursor read. The
validators now enforce that split: the `pascal` server must be byte-identical
in both files, `skills/.mcp.json` may add only `pascal-hosted`, and the hosted
Authorization header must stay a `user_config` reference so no literal
credential can ship in the published plugin source.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(changelog): link the hosted MCP entry to #835

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* ci(release): graduate prerelease versions on stable bumps (#837)

The stable-bump path split "1.0.0-beta.5" on dots, so major produced
2.0.0, minor 1.1.0, patch failed on "0-beta" arithmetic, and none would
have published a beta version on the latest dist-tag. Any stable bump on
a prerelease now yields its base version, matching npm semver, so the
1.0.0-beta.N line can be released as 1.0.0.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* perf(cli): prune build-only files from the portable runtime (#838)

`next build` copies its tracing root into `.next/standalone`, so the staged
runtime shipped app sources, repository documentation, build trace metadata and
assets that `server.js` never reads.

Pruned from `dist/runtime`:

- `public/audios/radios` (39.3 MB) — the radio catalogue is played by the hosted
  community app, which serves its own copy; nothing in this repository requests
  `/audios/radios`.
- `next/dist/server/capsize-font-metrics.json` + `font-utils.js` (4.1 MB) —
  `font-utils.js` is the only reader of the metrics and is itself unreachable
  from the standalone server.
- A stray 3.15 MB authoring screenshot and a duplicate `.glb` under
  `public/items` — item assets are addressed by convention, and anything else is
  now dropped and named on stdout.
- `apps/editor/{app,components,lib}` plus dev-only configuration and docs
  (0.5 MB) — TypeScript sources and tests that Node never executes.
- `.nft.json` build trace metadata and source maps under `.next` (0.7 MB).

Before: 107.5 MB tarball, 149.2 MB unpacked, 2956 files.
After:   64.6 MB tarball, 101.7 MB unpacked, 2870 files.

The release budget in the smoke test drops to 75 MB / 115 MB / 3200 files so the
regression cannot come back unnoticed. `stage-runtime` + `smoke-runtime` pass,
and the packed CLI still serves the editor, `/scenes`, a scene page with all 84
of its static chunks, and every sampled public asset.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* perf(editor): re-encode fitting thumbnails as 256px webp (#842)

`public/icons/fittings/` held 16 PNGs at 1254x1254 RGBA — 11 MB of assets
for thumbnails that render at 56 CSS px in the MEP tool options grid, and
11 MB of the 64.6 MB packed CLI runtime. Every other icon under
`public/icons` is already a small webp.

Each PNG becomes a 256x256 lossy webp with alpha (`cwebp -q 85 -m 6
-alpha_q 100 -resize 256 256`), which is still 2.3x the largest rendered
size — the portable build sets `images.unoptimized`, so the raw file is
what the browser scales. The directory drops from 11 MB to 164 KB.

`build-tab.tsx` derives the path from the fitting type, so the extension
in that template is the only reference to update.

Packed runtime smoke: 54.1 MB compressed, 91.0 MB unpacked, 2870 files
(was 64.6 MB / 101.7 MB / 2870).

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* chore(editor): drop unreferenced public assets (#843)

Remove 53 MB of committed assets nothing in this repository reads: the
small-kitchen-cabinet item (10.9 MB; the item catalog resolves every item
from remote storage and no demo references this slug), a stray authoring
screenshot, and the radio catalogue (39 MB) that only the hosted community
app plays from its own copy. The CLI staging script already pruned the
radios and the screenshot; its rm(force) calls tolerate their absence.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* ci(release): publish through npm trusted publishing only (#839)

The 1.0.0 release failed with EOTP on its first publish: npm no longer
accepts direct publishing with 2FA-bypass granular tokens. Drop
NODE_AUTH_TOKEN from every publish step so npm 11 exchanges the GitHub
Actions OIDC token instead. Requires each @pascal-app package to have
this repository, workflow file and the npm environment configured as a
trusted publisher on npmjs.com.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* feat(cli): ship a small CLI that downloads the web editor runtime (#845)

The npm package carried the whole standalone Next editor: 65 MB compressed,
102 MB unpacked, for 0.1 MB of CLI code. Agents that only speak MCP paid that
cost too, because the MCP bridge started the editor to reach it.

Split the two. `dist/` now holds the CLI plus `services/pascal-mcp.mjs` and a
`runtime-source.json` naming the web runtime archive for this exact version,
its size, and its SHA-256. The web editor runtime ships as a GitHub release
asset and is downloaded once per version, verified, and installed through the
existing atomic install seam.

- MCP is its own managed service (`run/mcp.json`), started on demand by
  `pascal mcp connect` with no editor process and no runtime download.
- Commands that start the editor resolve the runtime from
  `PASCAL_BUNDLED_RUNTIME_DIR`, `--runtime <directory-or-archive>`, the
  installed version, else the release asset; a digest mismatch deletes the
  temporary file and installs nothing.
- Downloads stream over `node:https` with `HTTPS_PROXY`/`NO_PROXY` support and
  no new dependency; concurrent first runs share the install lock.
- `stage-runtime` writes a deterministic `pascal-web-runtime-<version>.tar.gz`
  plus `.sha256`; the release job verifies both before publishing and uploads
  them to the CLI tag right after it is pushed.
- The smoke test now covers MCP-only startup with no runtime present and the
  local-archive install, including a one-byte tamper that must fail closed.

Package: 0.46 MB compressed, 2.46 MB unpacked, 68 files.
Archive: 64.2 MB compressed, 106 MB installed.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* docs(skills): describe the hosted-only capture tools (#846)

Document the hosted-only Capture scan path (list_captures, get_capture,
open_capture_as_project) in the pascal-3d skill and its tool workflows, and
scope the counted 46-tool annotation inventory to the public package so the
hosted server's extra tools do not read as a packet gap.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* feat(plugins): add optional hosted auth to the Cursor plugin (#849)

* feat(plugins): add optional hosted auth to the Cursor plugin

A Cursor install can now reach hosted Pascal — projects, Pascal Capture
scans and shared workspaces — with an optional API key, while the
credential-free local `pascal mcp connect` server keeps working.

`.cursor-plugin/plugin.json` declares an optional `PASCAL_API_KEY`
variable and points `mcpServers` at a new Cursor-dialect
`.cursor-plugin/mcp.json` that adds a `pascal-hosted` server for
https://editor.pascal.app/api/mcp. Cursor substitutes the bare
`${PASCAL_API_KEY}` plugin-variable form from its dashboard, so the
repository holds only the placeholder. The variable is absent from
`required`, so an install with no key still loads and only
`pascal-hosted` fails (401).

The portable `mcp.json` stays credential-free on purpose. Agent Plugins
1.0.0 forbids secrets and placeholder expansion in `headers` (7.2.3,
9.2), its only remote keyword is `streamable-http` rather than Cursor's
`http`, and Codex drops a plugin-supplied `Authorization` as a
client-owned header. Codex users therefore keep using
`codex mcp add --bearer-token-env-var PASCAL_API_KEY`.

ClawHub already declares `PASCAL_API_KEY` optional through
`metadata.openclaw.envVars[].required: false`, so the skills are
unchanged.

`bun run skills:validate` now asserts the Cursor MCP path, a `pascal`
server identical to the portable one, the exact hosted URL and header
template, the optional-and-never-required variable with no unsupported
schema keywords, and that no `${VAR}` in the Cursor config is
undeclared.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(plugins): keep the Cursor author block within Cursor's schema

Cursor's plugin.json schema allows only name and email under author
(additionalProperties: false); the url field failed validation on every
install. Compare the Cursor manifest's author on those two fields and
link the changelog entry to #849.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* perf: per-slab invalidation, candidate-scoped temporal reconciliation and rotation-stable surface planning (row 17) (#850)

* perf(nodes): invalidate slabs by derived polygon changes

* perf(core): scope temporal reconciliation to changed nodes

* fix(nodes): mirror rendered slab context membership and order

* perf(nodes): reuse slab inputs and scope polygon derivation

* test(core): verify structural temporal reconciliation outcomes

* docs: describe temporal candidates and slab dependency tracking

* perf(core): skip disjoint room coverage and rotated surface rewrites

Cache polygon bounds for indexed surface scoping and preserve exact cyclic
outer-ring rotations in the shared slab and ceiling planners.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jmmz2AMwTzcnKsSHHPEMhN

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* editor: integrate Environment with generic host and export APIs (#831)

* refactor(packages): fold capture packages into core and viewer (#851)

Avoid npm package sprawl before 1.0.0: `@pascal-app/capture-protocol`
becomes the `@pascal-app/core/capture` subpath and
`@pascal-app/capture-viewer` becomes `@pascal-app/viewer/capture` (plus
`@pascal-app/viewer/capture/preview`), so the release ships seven
packages: core, viewer, editor, nodes, mcp, ifc-converter, cli.

Neither package was ever published to npm, so no npm consumer migrates.
The protocol code is pure zod/TS, so core keeps its no-Three.js layer
rule; the runtime and its reference layers keep viewer's existing peers
and now reach viewer internals through relative imports instead of a
self-referential `@pascal-app/viewer` specifier.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* fix(editor): declare @react-three/test-renderer where the lifecycle test imports it (#852)

The registered tool lifecycle test imports @react-three/test-renderer, but
only the viewer workspace declared it. Hoisting hid the missing dependency;
private-editor CI uses Bun's isolated linker and cannot resolve that import
from the editor workspace. Declare the same ^9.1.0 development dependency
in editor and record it in the workspace lockfile entry.


Claude-Session: https://claude.ai/code/session_01Jmmz2AMwTzcnKsSHHPEMhN

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* ci(release): drop registry-url so npm uses OIDC trusted publishing (#853)

The 1.0.0 run failed publishing core with E404. actions/setup-node with
registry-url writes an .npmrc whose token falls back to the placeholder
XXXXX-XXXXX-XXXXX-XXXXX when NODE_AUTH_TOKEN is unset; npm sent that fake
token instead of exchanging the Actions OIDC token, and the registry
answered 404. Without registry-url no .npmrc is written and npm 11 falls
through to trusted publishing.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* ci(release): log npm verbosely to surface OIDC exchange errors (#856)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* fix(capture): cache preview artifacts, retry failed downloads, and clarify device-path visibility (#858)

* fix(capture): cache preview data and improve device path visibility

* fix(capture): recover failed JSON preview downloads

* test(viewer): preload one React instance before rendering hooks

* release: @pascal-app/core@1.0.0 @pascal-app/viewer@1.0.0 @pascal-app/editor@1.0.0 @pascal-app/nodes@1.0.0 @pascal-app/mcp@1.0.0 @pascal-app/ifc-converter@1.0.0 @pascal-app/cli@1.0.0

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: replace the CLI preview instructions with the published npm CLI (#859)

`@pascal-app/cli@1.0.0` is on the npm `latest` tag with `pascal agent claim`,
`pascal agent status`, and the read-only `check_collisions.candidate` input, so
the checksum-verified GitHub prerelease the docs pointed at is obsolete. Delete
the "Verified CLI preview" and "Verified GitHub preview" sections, stop
recommending the `beta` dist-tag (it still resolves to the older
`1.0.0-beta.1`), and drop the inverted claim that the npm package bundles the
web editor runtime — 1.0.0 downloads it from a release asset on first use.

Close the changelog's `Unreleased` heading as `1.0.0 (2026-09-12)` with the
package and contributor sections the earlier releases carry.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* docs: describe the release workflow and refresh the validation scope (#860)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(editor): keep manifold-3d out of consumer bundler graphs (#735)

manifold-3d's emscripten glue awaits import('node:module') behind a Node
check; the branch never executes in a browser, but webpack refuses to
build any graph that can reach it. export-manager.tsx statically imports
the manifold worker wrapper and ExportManager renders unconditionally
from the editor root, so every external webpack consumer of
@pascal-app/editor failed at build time (#715).

The worker chunk is still built by the consumer's bundler, but it no
longer contains a traceable manifold-3d specifier. The glue is loaded at
runtime through an import() no bundler follows: bare specifier first
(bun tests, dev servers, bundlers that inlined it anyway), then a
version-pinned jsDelivr copy for bundled browser builds — emscripten
locates manifold.wasm relative to the glue's own URL, so the CDN path
self-resolves. configureManifoldRuntime(options) lets offline or
CSP-restricted hosts point both URLs at self-hosted assets.

A failed load no longer poisons later attempts: the cached module
promise resets on rejection.

Fixes #715

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* mcp: make batch-first apply_patch the stated default (#767)

* docs(mcp): make batch-first apply_patch usage the stated default

Tool description, agent guide, from-brief preamble, and README now instruct agents to compose one atomic apply_patch batch per phase instead of looping single-op calls. The tool already validates all ops before applying any; only the guidance was missing.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* test(mcp): assert batch-first apply_patch guidance surfaces

Lock the tool description, agent guide, from_brief preamble, and README
row that state batch-first as the default without changing apply_patch
runtime behavior.

---------

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* fix(editor): type optional ancestor traversal for downstream consumers (#814)

Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com>

* fix(viewer): clamp GLB floor animation on slow frames (#820)

Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com>

* fix(skills): compare bundle paths without a hardcoded separator (#848)

`bun run skills:validate` fails on Windows for every cross-file link
inside a skill bundle and for both OpenAI interface assets, even though
each referenced file exists inside the plugin. `resolve()` returns
backslash-separated paths on Windows, so the `${dir}/` prefix compared
against never matched.

Compare on a normalized separator instead, and cover the predicate with
a focused test so the check stays platform-independent.

* Carry collections through Save Build / Load Build (#783)

Fixes #734

* fix(mcp): declare 2020-12 dialect for tools/list schemas (#787)

The MCP SDK emits tool schemas with a draft-07 dialect, so clients that
enforce JSON Schema 2020-12 reject every tool call. The generated schemas
use no draft-07-only keywords, so re-registering the tools/list handler to
retarget the declared $schema is sufficient.

Fixes #696

* fix(ifc-converter): preserve imported beam geometry (#841)

* fix(editor): skip roof support levels in the floorplan export (#847)

* fix(editor): skip roof support levels in the floorplan export

`resolveExportLevels()` collected every level child of the active
building and filtered on `type === 'level'` only, so a dedicated roof
support level (`metadata.role === 'roof'`) produced an extra page with
just the roof outline. `agent-guide.ts` already states that such a level
is not an occupied story, and the level UI and elevation math honour it;
the export did not.

Filter roof levels out of the export set and cover it with a regression
test, including the case where the roof level is the selected one.

Fixes #618

* ci(release): publish through npm trusted publishing only (#839)

The 1.0.0 release failed with EOTP on its first publish: npm no longer
accepts direct publishing with 2FA-bypass granular tokens. Drop
NODE_AUTH_TOKEN from every publish step so npm 11 exchanges the GitHub
Actions OIDC token instead. Requires each @pascal-app package to have
this repository, workflow file and the npm environment configured as a
trusted publisher on npmjs.com.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* feat(cli): ship a small CLI that downloads the web editor runtime (#845)

The npm package carried the whole standalone Next editor: 65 MB compressed,
102 MB unpacked, for 0.1 MB of CLI code. Agents that only speak MCP paid that
cost too, because the MCP bridge started the editor to reach it.

Split the two. `dist/` now holds the CLI plus `services/pascal-mcp.mjs` and a
`runtime-source.json` naming the web runtime archive for this exact version,
its size, and its SHA-256. The web editor runtime ships as a GitHub release
asset and is downloaded once per version, verified, and installed through the
existing atomic install seam.

- MCP is its own managed service (`run/mcp.json`), started on demand by
  `pascal mcp connect` with no editor process and no runtime download.
- Commands that start the editor resolve the runtime from
  `PASCAL_BUNDLED_RUNTIME_DIR`, `--runtime <directory-or-archive>`, the
  installed version, else the release asset; a digest mismatch deletes the
  temporary file and installs nothing.
- Downloads stream over `node:https` with `HTTPS_PROXY`/`NO_PROXY` support and
  no new dependency; concurrent first runs share the install lock.
- `stage-runtime` writes a deterministic `pascal-web-runtime-<version>.tar.gz`
  plus `.sha256`; the release job verifies both before publishing and uploads
  them to the CLI tag right after it is pushed.
- The smoke test now covers MCP-only startup with no runtime present and the
  local-archive install, including a one-byte tamper that must fail closed.

Package: 0.46 MB compressed, 2.46 MB unpacked, 68 files.
Archive: 64.2 MB compressed, 106 MB installed.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* docs(skills): describe the hosted-only capture tools (#846)

Document the hosted-only Capture scan path (list_captures, get_capture,
open_capture_as_project) in the pascal-3d skill and its tool workflows, and
scope the counted 46-tool annotation inventory to the public package so the
hosted server's extra tools do not read as a packet gap.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* feat(plugins): add optional hosted auth to the Cursor plugin (#849)

* feat(plugins): add optional hosted auth to the Cursor plugin

A Cursor install can now reach hosted Pascal — projects, Pascal Capture
scans and shared workspaces — with an optional API key, while the
credential-free local `pascal mcp connect` server keeps working.

`.cursor-plugin/plugin.json` declares an optional `PASCAL_API_KEY`
variable and points `mcpServers` at a new Cursor-dialect
`.cursor-plugin/mcp.json` that adds a `pascal-hosted` server for
https://editor.pascal.app/api/mcp. Cursor substitutes the bare
`${PASCAL_API_KEY}` plugin-variable form from its dashboard, so the
repository holds only the placeholder. The variable is absent from
`required`, so an install with no key still loads and only
`pascal-hosted` fails (401).

The portable `mcp.json` stays credential-free on purpose. Agent Plugins
1.0.0 forbids secrets and placeholder expansion in `headers` (7.2.3,
9.2), its only remote keyword is `streamable-http` rather than Cursor's
`http`, and Codex drops a plugin-supplied `Authorization` as a
client-owned header. Codex users therefore keep using
`codex mcp add --bearer-token-env-var PASCAL_API_KEY`.

ClawHub already declares `PASCAL_API_KEY` optional through
`metadata.openclaw.envVars[].required: false`, so the skills are
unchanged.

`bun run skills:validate` now asserts the Cursor MCP path, a `pascal`
server identical to the portable one, the exact hosted URL and header
template, the optional-and-never-required variable with no unsupported
schema keywords, and that no `${VAR}` in the Cursor config is
undeclared.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(plugins): keep the Cursor author block within Cursor's schema

Cursor's plugin.json schema allows only name and email under author
(additionalProperties: false); the url field failed validation on every
install. Compare the Cursor manifest's author on those two fields and
link the changelog entry to #849.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* perf: per-slab invalidation, candidate-scoped temporal reconciliation and rotation-stable surface planning (row 17) (#850)

* perf(nodes): invalidate slabs by derived polygon changes

* perf(core): scope temporal reconciliation to changed nodes

* fix(nodes): mirror rendered slab context membership and order

* perf(nodes): reuse slab inputs and scope polygon derivation

* test(core): verify structural temporal reconciliation outcomes

* docs: describe temporal candidates and slab dependency tracking

* perf(core): skip disjoint room coverage and rotated surface rewrites

Cache polygon bounds for indexed surface scoping and preserve exact cyclic
outer-ring rotations in the shared slab and ceiling planners.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jmmz2AMwTzcnKsSHHPEMhN

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* editor: integrate Environment with generic host and export APIs (#831)

* refactor(packages): fold capture packages into core and viewer (#851)

Avoid npm package sprawl before 1.0.0: `@pascal-app/capture-protocol`
becomes the `@pascal-app/core/capture` subpath and
`@pascal-app/capture-viewer` becomes `@pascal-app/viewer/capture` (plus
`@pascal-app/viewer/capture/preview`), so the release ships seven
packages: core, viewer, editor, nodes, mcp, ifc-converter, cli.

Neither package was ever published to npm, so no npm consumer migrates.
The protocol code is pure zod/TS, so core keeps its no-Three.js layer
rule; the runtime and its reference layers keep viewer's existing peers
and now reach viewer internals through relative imports instead of a
self-referential `@pascal-app/viewer` specifier.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* fix(editor): declare @react-three/test-renderer where the lifecycle test imports it (#852)

The registered tool lifecycle test imports @react-three/test-renderer, but
only the viewer workspace declared it. Hoisting hid the missing dependency;
private-editor CI uses Bun's isolated linker and cannot resolve that import
from the editor workspace. Declare the same ^9.1.0 development dependency
in editor and record it in the workspace lockfile entry.


Claude-Session: https://claude.ai/code/session_01Jmmz2AMwTzcnKsSHHPEMhN

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* ci(release): drop registry-url so npm uses OIDC trusted publishing (#853)

The 1.0.0 run failed publishing core with E404. actions/setup-node with
registry-url writes an .npmrc whose token falls back to the placeholder
XXXXX-XXXXX-XXXXX-XXXXX when NODE_AUTH_TOKEN is unset; npm sent that fake
token instead of exchanging the Actions OIDC token, and the registry
answered 404. Without registry-url no .npmrc is written and npm 11 falls
through to trusted publishing.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* ci(release): log npm verbosely to surface OIDC exchange errors (#856)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* fix(capture): cache preview artifacts, retry failed downloads, and clarify device-path visibility (#858)

* fix(capture): cache preview data and improve device path visibility

* fix(capture): recover failed JSON preview downloads

* test(viewer): preload one React instance before rendering hooks

* release: @pascal-app/core@1.0.0 @pascal-app/viewer@1.0.0 @pascal-app/editor@1.0.0 @pascal-app/nodes@1.0.0 @pascal-app/mcp@1.0.0 @pascal-app/ifc-converter@1.0.0 @pascal-app/cli@1.0.0

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: replace the CLI preview instructions with the published npm CLI (#859)

`@pascal-app/cli@1.0.0` is on the npm `latest` tag with `pascal agent claim`,
`pascal agent status`, and the read-only `check_collisions.candidate` input, so
the checksum-verified GitHub prerelease the docs pointed at is obsolete. Delete
the "Verified CLI preview" and "Verified GitHub preview" sections, stop
recommending the `beta` dist-tag (it still resolves to the older
`1.0.0-beta.1`), and drop the inverted claim that the npm package bundles the
web editor runtime — 1.0.0 downloads it from a release asset on first use.

Close the changelog's `Unreleased` heading as `1.0.0 (2026-09-12)` with the
package and contributor sections the earlier releases carry.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* docs: describe the release workflow and refresh the validation scope (#860)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(editor): keep manifold-3d out of consumer bundler graphs (#735)

manifold-3d's emscripten glue awaits import('node:module') behind a Node
check; the branch never executes in a browser, but webpack refuses to
build any graph that can reach it. export-manager.tsx statically imports
the manifold worker wrapper and ExportManager renders unconditionally
from the editor root, so every external webpack consumer of
@pascal-app/editor failed at build time (#715).

The worker chunk is still built by the consumer's bundler, but it no
longer contains a traceable manifold-3d specifier. The glue is loaded at
runtime through an import() no bundler follows: bare specifier first
(bun tests, dev servers, bundlers that inlined it anyway), then a
version-pinned jsDelivr copy for bundled browser builds — emscripten
locates manifold.wasm relative to the glue's own URL, so the CDN path
self-resolves. configureManifoldRuntime(options) lets offline or
CSP-restricted hosts point both URLs at self-hosted assets.

A failed load no longer poisons later attempts: the cached module
promise resets on rejection.

Fixes #715

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* mcp: make batch-first apply_patch the stated default (#767)

* docs(mcp): make batch-first apply_patch usage the stated default

Tool description, agent guide, from-brief preamble, and README now instruct agents to compose one atomic apply_patch batch per phase instead of looping single-op calls. The tool already validates all ops before applying any; only the guidance was missing.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* test(mcp): assert batch-first apply_patch guidance surfaces

Lock the tool description, agent guide, from_brief preamble, and README
row that state batch-first as the default without changing apply_patch
runtime behavior.

---------

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* fix(editor): type optional ancestor traversal for downstream consumers (#814)

Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com>

* fix(viewer): clamp GLB floor animation on slow frames (#820)

Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com>

* fix(skills): compare bundle paths without a hardcoded separator (#848)

`bun run skills:validate` fails on Windows for every cross-file link
inside a skill bundle and for both OpenAI interface assets, even though
each referenced file exists inside the plugin. `resolve()` returns
backslash-separated paths on Windows, so the `${dir}/` prefix compared
against never matched.

Compare on a normalized separator instead, and cover the predicate with
a focused test so the check stays platform-independent.

---------

Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com>
Co-authored-by: Aymeric Rabot <aymeric@pascal.app>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Wassim SAMAD <wass08@gmail.com>
Co-authored-by: Adam NAILI <18304870+AxiomeCG@users.noreply.github.com>
Co-authored-by: ActArtech <123718991+ActArtech@users.noreply.github.com>
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Co-authored-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com>

* fix(cli): preserve configured Mint host origin (#778)

* fix(cli): preserve configured Mint host origin

* ci: enable trusted publishing for MCP and CLI releases (#779)

* docs: add verified candidate CLI preview (#780)

* docs: add verified candidate CLI preview

* docs: activate preview runtime during upgrades

* Require measured evidence and target-scoped furniture checks (#781)

* Strengthen furniture fit evidence boundaries

* Record candidate validation status

* Clarify requested geometry scope

* Record furniture evidence gate results

* Document skill validation and safe preview activation (#782)

* Record final skill validation status

* Clarify routing audit result

* Document safe preview activation

* editor: Add duct and pipe fittings, routing, and system checks (#769)

* Add roof surface placement support for items

Items (e.g. solar panels) can now be placed on sloped roof surfaces.
The placement system computes euler rotation from the roof surface
normal so items sit flush on the slope instead of going inside.

- Add roofStrategy to placement-strategies with enter/move/click/leave
- Wire roof:enter/move/click/leave events in the placement coordinator
- Add calculateRoofRotation in placement-math using surface normals
- Support full 3D cursor rotation for sloped surfaces
- Items on roofs are parented to the level with world-space rotation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fixed conflict

* fix: pass nodes to lazy inspector panels

* MEP: unify duct and DWV routing UX

* Point editor dev runtime at local Streetscape plugin

* MEP: add exact lengths and branch affordances

* Make MEP runs surface-aware

* MEP: unify wall-aware duct and pipe run UX

- Add surface-aware drafting, snapping, and run attachments
- Support wall-attached run movement and endpoint updates
- Improve placement grid anchoring and semantic surface events

* MEP: free wall-attached routing and simplify fitting actions

- Continue routing horizontally after leaving a wall
- Keep quick material actions for pipe fittings only

* Fix duct and DWV direction capture from camera rays

* Align MEP snapping with architecture rules

* chore: satisfy repository checks

* test: scope pipe continuation handle assertion

* Add configurable MEP hangers and fix run drawing interactions

* Unify MEP accessory snapping and system connectivity

- Add shared snapping for MEP accessories with live setting updates
- Respect surfaces, levels, building transforms, and system boundaries
- Add coverage for snapping and cross-floor port connectivity

* Improve MEP connection feedback, slope controls, checks and hangers

* MEP: expand fitting catalogs and accessory configuration

- Add duct and DWV fittings, accessories, geometry, placement, and thumbnails
- Unify fitting selection through configurable tool options

* Simplify MEP build tools by removing the Add Trap action

- Remove the context-specific DWV Pipe Add Trap button from the Build tab

* Unify MEP run editing and placement UX

- Preview pipe and duct edits through live overrides
- Track fitting placement with interaction scopes
- Align surface-aware routing and accessory snapping

* Use live overrides for MEP selection previews

- Keep duct and pipe drag, roll, and offset previews out of committed scene state
- Render selection handles from live node overrides during interactions

* Simplify pipe routing status controls

* Make drafting behavior registry-driven

* Fix drafting history test registry setup

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* perf(editor): instance the ceiling corner brackets per level (#784)

* perf(editor): instance ceiling corner brackets per level

Replace the per-corner meshes with two level-wide InstancedMeshes sharing
one unit BoxGeometry. Legs and cubes use the same geometry/material path,
so their individual transforms fit in a single batch per opacity state.
Normal instances use 0.72 opacity and highlighted instances use 0.92;
instanceColor carries the original gray/indigo colors. Three 0.185.1's
StandardNodeLibrary maps MeshBasicMaterial to MeshBasicNodeMaterial, whose
NodeMaterial.setupDiffuseColor multiplies instanceColor into material color.
This requires no custom shader or per-camera sorting.

Keep the per-ceiling drag controllers memoized and non-rendering. Geometry,
height, live overrides and preview changes update that ceiling's matrices;
hover transfers only the affected parts between packed instance arrays.
Capacity doubles with headroom on overflow, count tracks occupied slots,
and React observes the batch store only when meshes are reallocated.
Conservative expanding spheres keep native raycasts valid after writes;
frustumCulled=false prevents stale render bounds from hiding handles.
InstancedMesh.prototype.raycast remains unchanged on the pointer fast path.

Use R3F's per-instance over/out events, with move reconciliation when packed
slots change ownership. Snapshot outgoing hover targets and pointer-down
part identities, allow clicks across highlight-batch transfers at the canvas
root, and retain stable React keys so R3F transfers interaction state on
capacity growth. Preserve the level portal and registry retry, ceiling:click
payload, drag/snap/SFX/override lifecycle, and synchronous capture hiding.

Accepted visual changes from the architect ruling:
- Normal brackets render at 1000 and highlighted brackets at 1001, making
  mixed overlaps deterministically highlighted-on-top.
- Ordering against other transparent objects at 1000 is now per batch,
  using the shared geometry centre at the level origin, rather than per
  bracket. There is no per-camera instance sorting.
No other intentional behavior changes.

Validation:
- Built the local core/viewer package outputs needed for editor validation.
- packages/editor: bun test src -- 848 pass, 0 fail across 119 files.
- Includes 12 new tests for instance indexing, highlights, capacity, old
  leg matrix parity, native raycasts, and mounted R3F hover/click/drag,
  override, capture, and unmount behavior.
- packages/editor: bun run check-types (tsgo --noEmit) -- passed.
- Root: bunx biome check on all four changed files -- clean.
- Runtime draw/frame measurements and pixel comparison remain with the
  architect; no browser or dev server was started.

* fix(editor): stabilize ceiling bracket picking and resource lifetime

Resolve equal-distance bracket hits by ceiling/corner/part identity for
hover, pointer-down and click. Packed instance IDs and opacity batch order
no longer decide the owner at coincident same-height ceiling corners.
Keep native InstancedMesh raycasting and the existing click payload.

Give each batch a clone of the unit box geometry and dispose that geometry
before retiring the mesh/material on growth or teardown. WebGPU owns
instance-attribute cleanup through its geometry disposal listener.

Use StaticDrawUsage for instance matrices and colors. Writes bump versions
and add update ranges for the affected slots. Clear source ranges after
rendering because TSL uploads internal attribute wrappers; their ranges are
consumed by the backend. The attribute scheduler regression verifies that
unchanged resting frames cause no attribute updates.

Poll sceneRegistry.revision and re-resolve the level only when it changes.
Keep a stable portal group attached beneath the current level so replacing
a level object does not remount the same primitives and lose R3F event
registration. Reparenting preserves the mesh, geometry and matrix buffers.
Read the live registry object for every drag-plane query as well. Retain
the initial requestAnimationFrame retry and synchronous capture hiding.
Document that ceiling:click.position remains level-local; do not transform
or otherwise change that payload.

The reviewer's normalView/MRT concern remains uncertain: overlapping faces
with different normals may change AO/ink output when batch order changes.
No normal/MRT changes are made here. The architect will check pixels with
ink and AO enabled. The previously accepted transparency ordering remains.

Validation:
- packages/editor: bun test src -- 852 pass, 0 fail, 119 files.
- New mounted regressions cover 20 repeated moves over coincident corners,
  stable click/drag ownership, and a translated/rotated same-id level
  replacement with unchanged geometry/matrix versions and local payloads.
- Unit regressions verify geometry dispose events on growth/teardown and
  Three's WebGPU attribute scheduler skipping unchanged frames while
  changed slots carry bounded update ranges.
- bunx tsc --noEmit -p packages/editor/tsconfig.json -- exit 0, no output.
- bunx biome check on all four changed files -- clean.

* docs(editor): note accepted small bracket matrix uploads

Accept whole-array uploads on every render for small matrix buffers using Three 0.185.1’s uniform BufferNode path; above the device uniform-buffer limit, the attribute path honors versions and update ranges.

* perf(nodes): skip animation mixers for items without clips (#785)

* feat(capture): add shared clay previews and dollhouse rendering

* docs(capture): document local previews and mesh presentation

* Split canopy regression matrix into independent tests

* feat(skills): fail closed on missing furniture inputs

* perf(nodes): batch ceiling undersides and slab bodies (charter row 16) (#789)

* perf(nodes): batch ceiling undersides and slab bodies

* fix(nodes): close surface batch ownership and rebuild lifecycles

* fix(editor): reconcile paint previews after apply exceptions

* fix(nodes): rebuild slabs and release batches on material cache clear

* fix(nodes): strip the merged wall batch from GLB exports

* fix(editor): include moved node identity in perf receipts

* fix(editor): preserve grid surface hits while batching

* fix: preserve batched surfaces in geometry raycasts

* test(nodes): run source-system probes from a package-local file, not bun -e (#792)

Fix private-editor CI's Lint, Typecheck & Test / Unit tests failure on Bun 1.3.0 Linux: eval probes started at the editor submodule root could not resolve @pascal-app/core from dependencies hoisted to the private root.

Write isolated probes under ignored package-local .turbo directories, resolve source imports and mocks from import.meta.dir, and remove probes in finally. Apply the same fix to the core parser test that imports zod from an eval probe. Preserve all cases and assertions.

Verified both dependency layouts, package and private-root test invocations, eval failure and file success from /tmp with automatic installs disabled, randomized nodes tests (seed 1), core parser tests, Biome, and no-emit typechecks.

* test(nodes): establish probe mocks before any fiber/react import (#793)

* test(nodes): establish probe mocks before any fiber/react import (Bun 1.3.0)

* test(nodes): make source-system probes linker-agnostic (isolated node_modules)

* skills: make furniture follow-ups blocker-aware (#794)

* feat(skills): add verdict-aware furniture follow-ups

* fix(skills): make furniture follow-ups blocker-aware

* fix(skills): enforce furniture action boundaries

* test(skills): pin furniture decision evidence

* docs: record agent skills 0.1.4 release source (#795)

* docs(skills): prepare OpenAI plugin submission

* docs(skills): complete OpenAI review fixtures

* docs(skills): prepare ClawHub publication

* fix(plugin): require MCP for OpenAI submission (#799)

* feat(mcp): add tool execution middleware

* docs(skills): record 0.1.6 as released

* fix(mcp): propagate tool cancellation

* fix(mcp): preserve executor on tool updates

* chore(skills): harden ClawHub bundles

* test(skills): reject ClawHub ignore overrides

* Add official MCP Registry publishing

* ci(mcp): verify live catalog consistency

* feat(skills): bundle local Claude MCP connector

* docs(skills): correct Claude MCP upgrade guidance

* docs(skills): record 0.1.7 release

* fix(skills): hide maintainer workflows from discovery

* fix(mcp): classify all tool side effects

* docs(openai): add tool annotation justifications (#813)

* feat(cli): add hosted agent claim command (#815)

* feat(cli): add hosted agent claim command

* fix(cli): require canonical claim expiry

* fix(ci): authenticate CLI npm publish (#816)

* fix(ci): restore OIDC for CLI publishing (#817)

* feat(cli): prefill hosted agent claim (#818)

* docs(cli): publish verified agent claim preview (#819)

* feat(cli): report hosted agent status (#821)

* docs(cli): publish verified agent status preview (#822)

* feat(skills): add human-openable fit prechecks (#824)

* docs(skills): record agent report release evidence (#825)

* perf: scope undo/redo invalidation to changed geometry and cleared previews (charter row 7) (#805)

* Scope undo invalidation to changed geometry and cleared previews

* Reset editor state before randomized store tests

* Resolve history probe mocks from each consuming package

* Restore discarded preview dependency closures on undo and redo

* Limit rendered slab invalidation to changed boundary bands

* Cover history support transfers and scoped endpoint rebuilds

* Pin endpoint history closure with spatial sync mounted

* Run package tests against core source without rebuilding dist

* test: drop the repo-wide core source preload

* test: verify consecutive undo and redo invalidation

Zundo 2.3.0 appends the just-left snapshot to both destination stacks, so the existing pre-jump length indices are correct. Cover three adjacency-changing moves and each undo/redo with cleared marks and flushed microtasks.

* fix: invalidate old slab covering dependents on reparent

Refresh covering dependents below both parent levels, deduplicating equal resolved levels. Cover reparent from level 2 to level 3 and undo with exact wall/ceiling sets and unrelated levels left clean.

* perf: drain initial wall builds within the time budget (charter row 6) (#800)

* perf: drain initial wall builds within the time budget

* fix(core): invalidate hydration atomically with scene edits

* test: isolate scene fixtures from randomized ordering

* fix(core): complete normalization before publishing hydration

* fix(viewer): preserve and bound initial wall drain lifetime

* docs: clarify hydration lifetime and wall drain counters

* Experience fix pass: placement, selection rotation, roof, stairs, capture, Cmd+S, three 0.186 (#807)

* fix(capture): round armed FOV, add Alt slow modifier for the drone camera

armCaptureFov stored the live camera FOV verbatim, so fractional pose FOVs
printed float tails in the HUD and left the reset button enabled. Both
writers now share clampCaptureFov.

Alt holds the drone at 0.2x speed and look sensitivity for fine framing;
Shift stays the boost.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* fix(roof): keep the gable shell base on the wall top

The CSG degeneracy guard enforced its 5 cm minimum by lowering the shell
base, which for wallHeight-0 room roofs put the gable 4 cm inside the
wall and z-fought its faces. Raise the eave instead; mirror the floor in
the opening-placement frame and the shed inset panel.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hQVFZ62S1qJDsBM9ferYc

* feat(editor): Cmd/Ctrl+S saves instead of opening the browser dialog

Capture-phase, always-on listener so the page-save dialog never appears.
Hosts can take the chord over via onSaveShortcut; the default flushes the
autosave through the existing executeSave path.

Co-Authored-By: Claude Fable 5.1 <noreply@…
* feat(editor): expose printable STL export

* fix(editor): address print STL export review feedback

Track in-flight format for aria-busy, align button labels, and make preparePrintExport format required.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
)

* Add /import?src=<url>: hand-off point for scanning apps

A scanning app (or any external tool) can now open
editor.pascal.app/import?src=<https-url> to hand a build JSON to the
editor. The fetch happens client-side in the visitor's browser (same
trust model as dropping a file on Load Build; the host must allow
CORS), the file runs through the same validateBuildJson pre-flight,
the visitor reviews the contents, and only an explicit click creates
the scene through the regular POST /api/scenes route — so auth,
origin checks and apiGraphSchema validation all apply unchanged.

src accepts https only (http for localhost during development), no
embedded credentials, 25 MB cap. Unit tests for the URL validation.

* Make the import fetch effect cancellable and reset on src change

Review feedback (Bugbot): a superseded or aborted fetch could
overwrite a newer state — including surfacing the cleanup abort as a
CORS error — and a src change left the previous review (and its
Import button) live against the old file. The effect now resets to
'fetching' on every src change and every state update from a
cancelled run is ignored.

* Align the import cap with the scene store limit

Review feedback (Bugbot): MAX_IMPORT_BYTES was 25 MB while the sqlite
scene store rejects graphs over DEFAULT_MAX_SCENE_BYTES (10 MB) — a
file could pass review then fail POST /api/scenes with a 413 shown as
a generic error. The cap now matches the store's limit, and a 413 gets
its own explanation.

* Guard scene creation against double-tap re-entry

Review feedback (Bugbot): a second tap on Import could fire before
React re-rendered into 'creating', creating two scenes and racing the
redirect. A synchronous useRef guard now blocks re-entry; it is
released in a finally so a failed create can be retried.

* Keep the review alive when scene creation fails

Review feedback (Bugbot): a failed POST switched to the error phase,
unmounting the review and the validated graph — nothing left to retry,
and refreshing re-fetches a src URL that may be short-lived. A create
failure now stays in the review phase with the error shown inline and
the button relabelled 'Try again'.

* Review fixes: bun:test, byte-accurate size cap, no lockfile noise

- import-src.test.ts now imports from bun:test like every other test
  under apps/editor/lib (vitest is not a repo dependency — the bun
  runner shimmed the import, which is why the suite did run, but the
  file was wrong and the description should have said bun test).
- The size cap measures real bytes via Blob, not UTF-16 code units —
  non-ASCII names could otherwise pass review and still 413.
- bun.lock restored to main (the sha512 additions were a bun
  regeneration artifact, not part of this change).

* biome format + key ImportClient by src

Two hunks collapsed per biome (createError ternary, createError JSX).
Bugbot's stale-sceneName note taken: the page keys <ImportClient> by
src, so a new file is a new mount and no state leaks between files —
the manual phase reset inside the effect is gone with it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Import page renders schema failure details

Bugbot: the shared validateBuildJson error says "see details below",
but the page listed only errors and warnings — a blocked import had no
per-node path or message. Same data Load Build already shows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* test: codify space-detection commit visibility

Adds the regression guard that would have gone red on pascalorg#554: asserts the
derived slab/ceiling writes land inside the originating local SceneCommit
rather than after it, plus a comment at initSpaceDetectionSync naming the
zundo snapshot boundary that makes the synchronous store subscription the
required home for reconciliation.

The test drives the real useScene singleton rather than the minimal store
stand-ins the rest of the file uses, because a stub cannot exercise that
boundary. It captures and restores the singleton's state in the finally
block so the mutation cannot leak into a later test.

No behavior change, hence test: rather than fix:.

* style: split the history-control import to satisfy biome lineWidth

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
…lorg#600)

* core: shared pure plan-footprint math for spatial-grid and MCP

Extract rotation-aware XZ footprint AABB/corners and expand-then-intersect
gap overlap into packages/core/lib/plan-footprint. Spatial-grid and
alignment-anchors delegate to it. Export via @pascal-app/core/plan-footprint
and spatial-grid. Design note documents one source and gap call-site rules
after pascalorg#569.

* core: biome format plan-footprint helpers

* core: narrow plan-footprint to corners + AABB

Answer CHANGES_REQUESTED on pascalorg#600 with the Narrow path: keep
planFootprintCorners and planFootprintAABB, drop the three unused
exports and their tests, and remove the named-attribution wiki page.
Gap call-site meanings stay in the module header as the seam for the
MCP consolidation follow-up.

---------

Co-authored-by: wolf10drc <alaa@golead.io>
…ge-guard

perf(core): guard bulk slab changes in the spatial-grid subscriber

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 48c5ce7. Configure here.

ed.setStructureLayer('elements')
ed.setCatalogCategory(null)
ed.setMode('build')
ed.setTool(null)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEP tile hides tool grid

High Severity

Clicking the MEP group now sets mode to build and tool to null while isMepActive only stays true for an already-active MEP tool or for mode === 'select' with mepOpen. That post-click state matches neither branch, so the MEP sub-grid never appears and duct, pipe, and HVAC tools cannot be chosen from Build.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 48c5ce7. Configure here.

@Aymericr Aymericr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I completed the two final requested changes on top of current main: imported meshes now have a stable geometryKey, and the branch is rebased while preserving the native IFC beam importer that landed afterward. The combined path keeps supported beams as editable blocks and sends only unsupported or failed native conversions through imported-mesh fallback. CI is green. Local verification: core full suite, nodes full suite (2,537 pass / 1 existing skip), IFC converter 37 pass / 6,417 assertions, all three package builds, and Biome.

@Aymericr
Aymericr merged commit fbf4d9d into pascalorg:main Sep 12, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.