Skip to content

chore: sync with upstream pascalorg/editor 1.0.0-beta (64 commits) - #7

Open
ss251 wants to merge 65 commits into
devfrom
chore/upstream-sync-beta
Open

chore: sync with upstream pascalorg/editor 1.0.0-beta (64 commits)#7
ss251 wants to merge 65 commits into
devfrom
chore/upstream-sync-beta

Conversation

@ss251

@ss251 ss251 commented Aug 8, 2026

Copy link
Copy Markdown

Brings the fork up to date with pascalorg/editor — 64 upstream commits, 1.0.0-beta across every package, Next.js 16.3. 18 files conflicted; all resolved.

Why now

Several upstream commits bear directly on the architectural-drawing ingest work:

Resolution principle

Upstream wins on core engine, geometry and persistence semantics. Magic Cabinet extensions were ported onto the new surfaces rather than reverting upstream refactors.

Upstream taken (core semantics)

File Resolution
packages/core/src/store/use-scene.ts Upstream's loadScene hierarchy — writes explicit parentId for Site → Building → Level. The scene authority rejects the parent/child asymmetry our version left behind. Kept inside our createSceneStore() factory. setInstalledPlugins was independently identical on both sides.
packages/mcp/src/bridge/scene-bridge.ts Upstream's single-setScene extra bag (pascalorg#597): collections + materials + plugin state land in one tracked write, so materials survive persistence. Replaces our follow-up setInstalledPlugins call, which setScene was silently clobbering.
packages/mcp/src/storage/sqlite-scene-store.ts Persist materials; adopt PASCAL_EDITOR_ORIGIN.
apps/editor/lib/graph-schema.ts + .test.ts Upstream's foreign-node validation: BaseNode envelope plus the AssetUrl allowlist walk. Supersedes our bundled-plugin-schema lookup and removes a @magic-cabinet/pascal-plugin/schemas import from a server route — a layer-boundary win per CLAUDE.md. Upstream's 15 tests replace our 3.
bun.lock Upstream, then regenerated by bun install.
package.json packageManagerbun@1.3.14.

Ours kept, ported forward

File Resolution
packages/mcp/src/transports/http.ts Kept creation-identity sessions (resolveIdentity, localWorkspace, identity-mismatch 403, per-session McpServer) — strictly richer than upstream's transport map — and grafted on upstream's GET /health. Complementary, not competing.
packages/mcp/src/bin/pascal-mcp.ts Kept the CreationContext server factory and hoisted initial-scene read; added upstream's PASCAL_INSTANCE_ID health envelope.
packages/mcp/src/transports/http.test.ts, operations/scene-operations.test.ts Ours — they exercise the identity-aware factory signature.
apps/editor/lib/bootstrap.ts Ours — loads the Magic Cabinet plugin, keeps the trees gate.
apps/editor/components/scene-loader.tsx Union of both import sets.
Dockerfile Ours (nginx + editor + MCP, port 8080), plus upstream's Next-16 lesson: a real nodejs is installed because bun's node shim segfaults under next build. Base image → oven/bun:1.3.14.
.dockerignore Ours — a superset, and it carries the !packages/magic-cabinet-engine/dist negation.
.env.example, README.md Union of both sections.

Two changes worth a second look

1. @magic-cabinet/pascal-plugin was pinning @pascal-app/* at ^0.9.2. Upstream's bump to 1.0.0-beta meant bun stopped linking the workspace packages and installed a stale published copy of @pascal-app/core into packages/magic-cabinet-plugin/node_modules. Two copies of core, two incompatible type identities, and the build failed on a phantom missing baseElevation / CameraPose. Pinned to "*" to match the rest of the workspace.

2. The Mint plugin upstream added is deliberately not wired in. @mint/pascal-plugin is an external github: dependency behind Mint OAuth against editor.pascal.app. Omitted: the dependency, app/api/plugins/mint/[...path]/route.ts, the next.config.ts transpile entry, the globals.css @source, and the .env.example note. packages/cli is untouched. This is a product call, not a merge call — say the word and it is one commit to add back.

MCP tool-surface delta

No tools added, removed, or renamed. 54 registered before, 54 after, names identical. Verified live against the built server.

Behaviour did change inside three existing tools:

  • measure — polygon area is now net of holes (shoelaceArea(polygon) - holes, floored at 0); description now says "net area". Zone nodes are unaffected. Directly relevant to drawing-ingest area accuracy.
  • verify_scene — now also reports door keep-out violations and rotation-aware item-item footprint overlaps; issues gained a floorIndex field.
  • check_collisions, create_room / add_door / add_window / furnish_room — reworked upstream alongside the new door-clearance and layout-clearance modules.

New internal modules: door-clearance.ts, layout-clearance.ts, output-schema-contract.test.ts.

Gates

All green.

bun install                     705 packages, lockfile regenerated
bunx turbo run build            9 successful, 9 total
bunx turbo run check-types      12 successful, 12 total
bun test packages/mcp           376 pass, 0 fail
bun test packages/core          950 pass, 0 fail
bun test packages/magic-cabinet-plugin  111 pass, 0 fail
bun test apps/editor            29 pass, 0 fail
bunx biome check                Checked 1680 files. No fixes applied.

Live MCP on port 8099 (bun packages/mcp/dist/bin/pascal-mcp.js --http --port 8099):

  • initializeserverInfo: pascal-mcp-server 1.0.0-beta.5
  • tools/list → 54 tools, including upstream's create_wall, create_level, cut_opening, place_item, validate_scene, measure
  • and ours: create_cabinet_run, start_magic_kitchen_session, update_magic_kitchen, set_kitchen_palette, set_kitchen_handles, set_kitchen_camera, get_kitchen_bom, validate_magic_kitchen_session
  • GET /health → 200 with the instance envelope (upstream's new endpoint, through our path guard)

Not verified

  • No runtime render check of an existing kitchen scene. The loadScene hierarchy change adds an explicit parentId where our version relied on children alone — type-checked and unit-tested, but not eyeballed in the 3D editor.
  • Docker image not built; the Next-16 nodejs fix is ported from upstream's reasoning, not independently reproduced.
  • @pascal-app/cli is new from upstream and was not exercised.

🤖 Generated with Claude Code

wass08 and others added 30 commits July 28, 2026 16:43
Terrain sculpting and terrain-aware vertical editing
…1 @pascal-app/editor@1.0.0-beta.1 @pascal-app/nodes@1.0.0-beta.1 @pascal-app/mcp@1.0.0-beta.1 @pascal-app/ifc-converter@1.0.0-beta.1
Auto-generated room floors and ceilings, and any node whose geometry
builder baked its own vertical origin, assumed the plane `y = 0`. On
sculpted ground that left room slabs and ceilings floating above or
buried under the walls that generated them, and plugin-shipped kinds
(the Nature plugin's trees, flowers, grass) planted at the storey plane
instead of on the hillside.

The root cause was that terrain was opt-in per kind: the only way to ask
for the ground was `terrainSupportLift(...) ?? 0`, so every new consumer
had to remember to ask, and the ones that forgot silently got a flat
world.

Three seams close that:

- `levelBaseElevationAt(nodes, levelId, x, z)` — a total function for
  "how high is the floor of the world here", alongside the existing
  nullable `terrainSupportLift` ("is there terrain here"). Consumers
  resolve a base through it instead of hardcoding a zero.
- `ctx.levelBaseAt(x, z)` on `GeometryContext` — how a pure builder,
  which must not import the scene store, inherits terrain. Calling it
  also enrolls the kind in terrain invalidation, so asking for the
  ground is the registration: a plugin follows sculpted terrain with no
  flag, capability, or core change.
- Rooms derive their surfaces from the walls that enclose them. Auto
  floors take the highest wall base and auto ceilings the lowest wall
  top — the only pair that cannot open a hole, since a floor at the
  lowest base leaves daylight under the higher walls and a ceiling at
  the highest top pokes through the shortest one. Both stay flat (a slab
  is one scalar elevation by schema), so a room on a slope is a level
  room cut into the hillside, with the low-side walls filling down to
  meet it. This replaces `consensusElevation`, which abstained whenever
  the walls disagreed and so never placed a surface on a slope at all.

The wall geometry signature now folds in a terrain sample taken at the
same point the placement samples, so a stroke that moves ground under a
room re-derives its surfaces — sculpting touches only `site.terrain`, so
without that term every signature stayed byte-identical and the sync
early-exited. Sampling (not hashing the field, not resolving the full
slab election) keeps it per-stroke and avoids folding slab polygons into
the signature.

Also memoizes `siteOf` on the `nodes` identity — it is called per wall
per frame and per wall per store update, where an O(N) scan made those
callers O(N²).

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

fix: inherit terrain elevation in rooms, geometry builders, and plugins
…2 @pascal-app/editor@1.0.0-beta.2 @pascal-app/nodes@1.0.0-beta.2 @pascal-app/mcp@1.0.0-beta.2 @pascal-app/ifc-converter@1.0.0-beta.2
…ation-resilience

Fix realtime terrain and scene collaboration primitives
…3 @pascal-app/editor@1.0.0-beta.3 @pascal-app/nodes@1.0.0-beta.3 @pascal-app/mcp@1.0.0-beta.3 @pascal-app/ifc-converter@1.0.0-beta.3
…alidation

Stored scenes could link site → building → level through children arrays
while building/level carried parentId null: loadScene never wrote parent
ids and the legacy embedded-site-child flatten kept the already-flat
node's null parentId. The editor traverses children and renders these
scenes, but the hosted scene authority validates parent/child symmetry
and rejected every snapshot into a fatal read-only session.

healSceneNodes now repairs a null parent link when exactly one parent
claims the node (embedded legacy site children claim by id), loadScene
writes explicit parent links, and both migrations are exported
server-safe through @pascal-app/core/scene-migrations so the hosted
authority can apply the same normalization before validating.

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

core: repair null parent links so stored scenes pass authority validation
…4 @pascal-app/editor@1.0.0-beta.4 @pascal-app/nodes@1.0.0-beta.4 @pascal-app/mcp@1.0.0-beta.4 @pascal-app/ifc-converter@1.0.0-beta.4
…y policy and code of conduct (pascalorg#575)

* fix(mcp): declare every field the tools actually return

`get_level_summary` returned `floorIndex` and `create_from_template`
returned `saveSkipped`, neither of which was declared in the tool's
`outputSchema`. The MCP SDK generates the JSON Schema with
`additionalProperties: false`, so once a client has cached the schema from
`tools/list` it rejects the response:

    MCP error -32602: Structured content does not match the tool's output
    schema: data must NOT have additional properties

Every real host — Claude Desktop, Claude Code, Codex, Cursor — calls
`tools/list` before `tools/call`, so both tools failed in production on an
empty default scene while passing the whole test suite.

The suite missed it because the SDK client only validates
`structuredContent` once it has cached the schema, and no test called
`listTools()` first. `output-schema-contract.test.ts` connects a client
that lists before calling and exercises the read-only tools, so a payload
field added without a matching schema entry now fails CI.

Reported in pascalorg#566, which found `get_level_summary`; `create_from_template`
turned up in the same sweep once the harness could see it.

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

* docs: add a security policy and code of conduct, fix the Discord invite

The README's Discord badge pointed at an expired invite (pascalorg#552); this uses
the same non-expiring invite the hosted app already advertises.

The repo had no SECURITY.md, so there was no stated way to report a
vulnerability privately — the only visible channels were public issues and
discussions. It now points at GitHub private reporting and
security@pascal.app, and says what is in scope.

Adds Contributor Covenant 2.1 as the code of conduct, and a Contributing
section in the README linking both files plus the plugin guidance that
CONTRIBUTING.md already gives.

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

* chore: stop tracking apps/ifc-converter/next-env.d.ts

Next rewrites the route-types import in `next-env.d.ts` depending on whether
it was last touched by `next dev` (`.next/dev/types`) or `next typegen` /
`next build` (`.next/types`), so a tracked copy shows up as a spurious diff
in unrelated PRs. `apps/editor` already ignores it; move the rule to the root
ignore so it covers every Next app instead of being repeated per app.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ayers (pascalorg#576)

Hosts that render overlays inside `<Viewer>`'s children seam had no way to
match wall-cutout visibility: they either re-derived the camera facing
dot-product (which drifts from the source of truth) or inferred state from
the assigned material variant's `transparent` flag. The predicate is already
pure, so export it as-is and give `wallMode` a named `WallMode` type instead
of a bare `string`.

Closes pascalorg#572
pascalorg#530)

Roof-segments migrated on scene load are cast rather than zod-parsed, so
a segment saved before `trim` existed reaches the renderer with the field
absent and the geometry key crashed reading `trim.left` off undefined.

Route the key through core's `normalizeRoofSegmentTrim`, matching what
`use-segment-trim-clip` and `ridge-vent/geometry` already do. Besides
handling the absent field, this makes the cache key agree with the trim
the geometry is actually built from — an out-of-range trim normalizes to
a clamped value, so keying on the raw field could otherwise vary while
the built mesh did not.

Co-authored-by: Aymeric Rabot <aymeric.rabot@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Every `grid:move` built a fresh `BoxGeometry`, disposed the previous one,
and reassigned it — allocation and GPU buffer churn on the editor's
hottest interaction path.

Mount one unit `boxGeometry` and scale the mesh instead. The preview is
purely visual (nothing raycasts against it), so scaling is equivalent to
rebuilding at the target dimensions.

Extends the original wall-only change to the fence tool, which carried a
byte-identical copy of the same update function.

Co-authored-by: Aymeric Rabot <aymeric.rabot@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
`measure` computed gross shoelace area for slabs and ceilings, ignoring
`holes` — so a floor with a stairwell opening reported the area of the
uncut polygon. Every other net-area path in the repo already subtracts
them (`validate-build-json`, `polygonSurfaceArea` behind the quick-measure
HUD), so MCP disagreed with what the editor showed for the same surface.

Zones have no `holes` field, so their behavior is unchanged.

The regression test for an absent `holes` field goes through `loadJSON`,
which casts its nodes into the store without a schema parse — that is the
real path to a document where the field never existed, rather than
mutating the live store dict `getNodes()` returns.

Co-authored-by: Aymeric Rabot <aymeric.rabot@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…g#490)

Placing a plugin node (`trees:tree` from the first-party Nature pack) in a
saved scene made every later autosave fail with 400: `apiGraphSchema`
validated every node against the static `AnyNode` union, which cannot
enumerate kinds a plugin registers at runtime.

A node whose `type` is outside `AnyNode` is now validated the way
`validate-build-json` already treats a kind it cannot resolve — as a foreign
node, held to the `BaseNode` envelope plus core's `AssetUrl` allowlist applied
to every URL-shaped string it carries. Membership is decided by "not in
`AnyNode`", not by a namespace pattern: `plugin-authoring.md` requires plugin
*ids* to look like `vendor:pack`, never kinds, and its worked example
registers `kind: 'couch'`.

Reusing `AssetUrl` keeps the Phase 3 posture intact on a branch that has to
accept unknown fields. A scheme denylist would have to enumerate every hostile
scheme; `AssetUrl` already enumerates the safe ones, so `//evil.example`,
`ws:`, `gopher:`, `about:blank`, the instance-metadata endpoint, and
control-character-obfuscated `java\tscript:` are all rejected, and
`PASCAL_ALLOWED_ASSET_ORIGINS` keeps narrowing https origins on this path too.
Only URL-shaped values are checked, so a plugin can still store prose in
`name` / `metadata` exactly as builtin nodes do.

The URL scan is depth- and visit-bounded. An unbounded walk over a deeply
nested body throws `RangeError` past `safeParse`, which the route answers as a
500 where the contract is a 400 with issues.

Co-authored-by: Aymeric Rabot <aymeric.rabot@gmail.com>
Co-authored-by: Kone Venkatesh <konevenkatesh@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* respect millimetre notation in 3d labels

* fix(editor): thread millimeter notation through remaining 3d labels

The original change covered the measurement pill and a handful of panels
but left every other live readout formatting in meters, so switching to
millimeters silently applied to only part of the UI. The wall length
label — the most visible measurement in the editor — was among the
surfaces still hardcoded to the meter format.

Thread `metricNotation` from the viewer store into the remaining label
surfaces: wall and fence draft tools, wall measurement annotations, site
edge labels, elevation guides, the terrain brush cursor, placement boxes
(items and cabinets), zone quantities, and the ceiling panel.

The draft tool effects gained `metricNotation` in their dependency lists
so the live pointer handlers re-register when the preference changes.

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

---------

Co-authored-by: Aymeric Rabot <aymeric.rabot@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The repo has 313 test files (~60k lines) but nothing ran them on a pull
request: `ci.yml` only did lint + type check, and `mcp-ci.yml` covered
only packages/mcp plus two apps/editor files. So roughly 2,200 of the
2,491 tests never executed as a gate.

- add a `test` task to turbo.json (`dependsOn: ["^build"]`) and a root
  `test` script, then a `Test` step to ci.yml
- add the missing `test` scripts to packages/viewer, packages/editor,
  packages/ifc-converter and apps/editor, which all contained tests but
  had no way to run them
- add `@pascal-app/core` to packages/viewer devDependencies. It was
  declared only as a peerDependency, which Turbo does not traverse, so
  `^build` resolved to nothing and viewer's tests could not resolve
  core's `dist/`. editor/nodes/mcp already declare it in both places.
- scope each test glob to `src`/`tests`/`lib` so compiled tests under
  `dist/` are not collected a second time
- document `bun test` in SETUP.md and CONTRIBUTING.md

Verified from a clean tree (no dist, no turbo cache): 12/12 tasks,
2,491 tests, 0 failures; warm re-run is fully cached.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…EPO-EDITOR-59) (pascalorg#455)

* fix(viewer): centralize renderer capability fallback

* fix(viewer): recover from renderer initialization failures

* fix(viewer): harden GPU renderer fallback

* fix(viewer): keep the discrete-GPU hint and release abandoned WebGPU devices

Supplying `device` to WebGPURenderer makes three skip its own
`requestAdapter` (three.webgpu.js:83922 `if (parameters.device ===
undefined)`), so R3F's `powerPreference: 'high-performance'` default was
silently dropped and dual-GPU users could land on the integrated GPU.
Thread it from the props R3F hands the gl factory into the adapter
request instead of hardcoding it.

Owning the device also means owning its lifetime: three deliberately
declines to destroy a caller-supplied device, and `Renderer.dispose()`
early-returns when `init()` never completed, so the WebGPU->WebGL
fallback path leaked the device. Destroy it explicitly there, and
reclaim a device that resolves after the request has already timed out.

Also document why the gl factory returns a never-settling promise — that
is the part that actually silences MONOREPO-EDITOR-59, since the old code
rethrew into R3F's uncaught `await glConfig(...)`.

---------

Co-authored-by: Anton Pascal <anton-pascal@users.noreply.github.com>
Co-authored-by: Aymeric Rabot <aymeric.rabot@gmail.com>
…ed (pascalorg#577)

`@pascal-app/lingo` builds its unit registry at module-eval time and
`registerKind` deep-copies each kind definition with `structuredClone`, so
a browser without that global (Chromium <98, reported from Honor Browser
9.8 as `ReferenceError: structuredClone is not defined`) fails while the
module graph is still evaluating. Sentry MONOREPO-EDITOR-FB.

The shim belongs next to the import that makes lingo load-reachable, not
in an app entry: `packages/editor` is what the OSS app, the hosted app and
every npm consumer all load. An app-level polyfill covers only the app
that declares it.

Scoped narrowly and documented as such — lingo's kind table is plain JSON,
so a JSON round-trip suffices and needs no new dependency. It is not
spec-compliant and must not be relied on for real structured-clone
semantics.
Aymericr and others added 30 commits August 4, 2026 16:53
…ascalorg#584)

`two-bedroom.ts:13-14` documents that positive z points south; `garden-house.ts`
relied on the same convention without saying so, which read as a contradiction
(pascalorg#362 was filed partly on that basis). The geometry already agrees — `wall_n`
sits at `-HOUSE_D`, `wall_s` at `+HOUSE_D`, and `zone_garden` extends further
into -z — so this records the assumption rather than changing it.

The `GARDEN_DEPTH` comment said "along +z direction" while the garden is built
at `-HOUSE_D - GARDEN_DEPTH`; corrected to -z.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
`ci.yml` used `oven-sh/setup-bun@v2` with no version, so the main gate ran
against whatever bun was latest at the time. A bun release could then break CI
with no change in the repo, and CI could disagree with what contributors run
locally — the worst kind of red build, because bisecting the repo finds nothing.

`mcp-ci.yml` already pinned, but to 1.3.0, so the two workflows tested different
runtimes. `packageManager` also said 1.3.0 while the committed lockfile is
produced by 1.3.14. All three now name one version.

Also adds `permissions: contents: read` to the quality job, matching mcp-ci.

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

* perf(core,viewer): stop rebuilding wall geometry every frame

Three separate hot paths were doing full-scene work per frame on a
1081-wall floor, adding up to 5.6 s of main-thread stalls per six
scroll ticks:

- findJunctions was O(J x N) over every wall end; a spatial-grid
  prefilter narrows it to real neighbours (level assembly 59.3 s ->
  0.01 s, calculateLevelMiters 436 ms -> 10.8 ms, identical output)
- miter data is now cached per level and keyed on the exact wall
  inputs, so draining the dirty queue no longer recomputes it
- getLevelElevations was called inside the per-wall loop; memoised
- slab support and the wall appearance key were both unstable, which
  retriggered opening cutouts on every frame for no reason

Measured on scene e5f5822f8837, six wheel ticks in split view:
long tasks 5652 ms -> 4322 ms -> 0 ms.

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

* fix(viewer): clear the level miter cache when the wall system unmounts

The cache lives at module scope, so it outlived the mount it was created
for: every level ever visited kept its wall array reachable, and a remount
or a second project in the same tab simply added more. Editor teardown
already resets the other shared singletons; the cache now does the same
from the system's unmount effect.

Also records the rule in the systems wiki, since the same trap is waiting
for the next module-level memo.

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

* fix(core,viewer): keep junction order stable and cover the miter cache

The grid prefilter visits the per-cell bucket before the oversized-wall
fallback, so a wall spanning more than JUNCTION_GRID_MAX_CELLS_PER_WALL
cells was appended after shorter walls it precedes in the input. Collinear
walls overlapping a junction tie on angle, and the sort in
calculateJunctionIntersections is stable, so that reordering picked the
other wall's thickness for the miter: a 20 m facade with a collinear infill
of a different thickness moved the spur's boundary by 0.32 m. Restore the
input order before appending.

Extract the level miter cache so it is reachable from a test — replacing
sameMiterInputs with `return true` previously left the whole suite green,
which meant the cache the PR is built around had no coverage at all.

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

* perf(core): key the level-elevation memo weakly

The single-slot memo held a strong reference to the whole node record, so
closing a project left its entire graph reachable until the next call. The
adjacent wrapper in terrain-support.ts already uses a WeakMap for the same
value; match it.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
* mcp: layout clearance for doors and item overlaps

Prevent furnish_room from placing furniture in door keep-outs or on other
items. Add rotation-aware footprints, smart lateral/inset re-place, and
report remaining issues from verify_scene and check_collisions.

* mcp: fix layout clearance review findings and add error log

Scope doors/items by level, keep planned room entrances when other
doors exist, treat gap as minimum free space, honor item scale, and
document pitfalls in docs/layout-clearance-error-log.md.

* mcp: tighten planned keep-out coverage and skip reasons

Require planned center + 50% area overlap before treating a door keep-out
as covering a room entrance. Report primary pose reject reasons so
furnish skips cite door/overlap instead of last-nudge outside_bounds.

* mcp: give living tv-stand door-wall nudge axes

TV sits on the door wall and hits keep-outs; pass along/inward so
findValidPlacement can inset into the room instead of world-axis nudges.

* mcp: fix the quality gate on layout clearance

Two separate failures, the second hidden behind the first:

- Biome import ordering across 7 files (`bun run check:fix`), plus the
  `useOptionalChain` warning in `collectDoorKeepouts`.
- Three type errors in the clearance helpers, which CI never reported
  because the lint step failed first and short-circuited the job.

The type errors were both real signature problems, not noise:

- `Pick<AnyNode, 'id' | 'position' | 'width'>` cannot work — `position`
  and `width` exist on only some members of the `AnyNode` union, so
  `Pick` rejects the keys outright. Replaced with an explicit
  `DoorOpeningLike`.
- `Pick<WallNode, 'id' | 'start' | 'end'>` brands the id as
  `wall_${string}`, but `keepoutForPolygonEdge` intentionally passes a
  synthetic `edge-N` segment for room edges that have no wall node yet.
  Replaced with `WallSegmentLike`, which is the shape these helpers
  actually accept.
- `new Map(list.map((n) => [n.id, n] as const))` infers the branded
  `AnyNodeId` key type, so `resolveNodeLevelId(node.id, byId)` failed on
  a plain `string`. Annotated as `Map<string, AnyNode>`.

Gates: `bun run check` clean, `check-types` 9/9, core 917 / mcp 321 /
nodes 939 tests pass, 0 fail.

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

* mcp: keep check_collisions reporting real overlap, not proximity

Consolidating check_collisions onto findItemItemCollisions also handed it
that helper's DEFAULT_ITEM_GAP of 8cm, which is not the same question.

The gap exists for furnish_room: when *placing* a new item you want
breathing room around it, so "is this spot free" means "free plus 8cm."
check_collisions answers a different question about an existing scene —
"do these footprints actually intersect" — and an 8cm gap makes it report
furniture merely standing next to other furniture as a collision. Two
1m items 7cm apart came back as overlapping.

Passes gap: 0 explicitly and adds the tight regression test the suite was
missing; the existing "do not overlap" case placed its items 20m apart,
so nothing caught the change.

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

---------

Co-authored-by: wolf10drc <alaa@golead.io>
Co-authored-by: Aymeric Rabot <aymeric.rabot@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(viewer): per-level base elevation parameter

baseElevation is an offset: it shifts the level and every level above
it within the same building (cumulative). Integrated into the level
stacking computation so stacked, exploded, solo, floorplan, and
snap-to-true-positions all respect it. Includes focused tests.

Closes pascalorg#209

* fix: thread baseElevation through slab clamps, elevator stacking and migration

Three Bugbot findings, all the same shape: baseElevation was applied in
one path and ignored in another.

- Covering-slab math assumed the floor above sat exactly one stored storey
  height away, so wall and ceiling clamps ignored the offset. A positive
  offset over-shortened walls under thick slabs and a negative one let them
  penetrate the slab above. Floor-to-floor distance now comes from the
  stacked elevations (above.baseY - current.baseY) via one helper, so the
  clamp math and getLevelElevations cannot drift apart.
- Stair rise used the stored storey height for the same reason; it now uses
  the same helper.
- Elevator level tables and the first-person elevator colliders built
  cumulative Y from storey heights, so cab stops desynced from the visible
  floors. Both now read baseY from getLevelElevations. first-person-controls
  had its own near-copy of that logic, which is deleted in favour of the
  shared resolveElevatorLevels.
- Levels loaded from older project JSON could omit baseElevation, which made
  the editor control render NaN. Migration now normalizes it to 0 alongside
  level and children, with a defensive fallback at the control.

* viewer: stop the level-system test mocking core, and fix the type gate

The quality gate was red for two reasons.

1. `frameCallback?.({}, delta)` typed `never`. The useFrame mock assigns
   that binding while LevelSystem() runs, which TypeScript cannot see, so
   after the `frameCallback = null` reset it narrowed the binding to
   `null`. Reading it through a function keeps the declared type.

2. `mock.module('@pascal-app/core', ...)` replaced core's entire export
   surface. mock.module is process-wide and Bun does not restore it, so
   every viewer suite that ran after this file got the fake core: running
   the systems directory went from 47 pass / 0 fail to 29 / 2, and a
   whole-repo run lost 32 tests, including
   wall-support-extension's covering-slab case. The failure looked like a
   baseElevation regression and was not one.

   Core does not need mocking here — `sceneRegistry` is a real in-memory
   store with a `clear()`, and `useScene` is a zustand store with
   `setState`. The test now drives both directly and only mocks
   `@react-three/fiber` and `use-viewer`, the two modules that genuinely
   need a renderer or a React context. Dropping the `lerp` mock too: the
   real one is already pure.

Also adds the now-required `baseElevation` to the level fixture in
wall-drafting.test.ts — the schema default makes it required on
LevelNode's output type, so the existing `as AnyNode` cast no longer
held.

Gates on the merge ref (main merged in): check clean, check-types 9/9,
test 12/12 tasks with 0 fail, build 7/7.

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

* test(core): build a fresh nodes record when re-asserting the lowered base elevation

pascalorg#556 landed an identity-keyed WeakMap memo on getLevelElevations after this
branch last ran green. The new base-elevation case mutated `stackedNodes`
in place, so the second resolveStairTotalRise call handed the memo the same
object and got the cached 2.9 back instead of 2.1.

The memo's contract holds in production — every store write publishes a new
record (updateNodesAction spreads into `nextNodes`) — so the fix belongs in
the test. The sibling storey-height case in this same file already builds a
fresh record; match it.

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

* fix(core): take the highest ceiling in the stack as the elevator shaft top

The editor-side helper this PR consolidates into resolveElevatorLevels
guarded the stack top with Math.max over every level; the core version
reads only the topmost level. With the new baseElevation that is no longer
the same thing — a negative offset can sink the top level's ceiling below
the level beneath it, and the shaft then tops out under a served level and
clips the cab (3 m -> 2.5 m for a -2.5 m offset on a 2 m top storey).

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

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Aymeric Rabot <aymeric.rabot@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ride (pascalorg#587)

Two package scripts use POSIX shell syntax that Bun's own shell does not
implement, and Bun uses that shell for `bun run` on Windows:

- `apps/editor`'s dev script passes `--port ${PORT:-3002}`, which arrives at
  Next verbatim: `option '-p, --port <port>' argument '${PORT:-3002}' is
  invalid`. Setting PORT does not help — the literal is never expanded.
- the root dev script starts `set -a && . ./.env`, and `set` is not a Bun
  shell builtin, so it prints `bun: command not found: set` and silently
  skips loading `.env` entirely.

Reproduced both on macOS with `bun run --shell=bun`, which selects the same
shell Windows gets:

    $ echo port=${PORT:-3002}
    port=${PORT:-3002}          # even with PORT=9999 in the environment
    $ set -a && echo set-worked
    bun: command not found: set

Hardcoding the port would fix Windows but drop the PORT override that
SETUP.md and .env.example both document. Instead, load a committed
`.env.defaults` last and let `next dev` read PORT from the environment
(the CLI already declares `.env('PORT')` on `-p, --port`). Nothing is
shell-expanded, so it behaves the same on every platform, and the
precedence stays shell PORT > .env.local > .env.defaults — verified at
3002 by default and 4321 with an override, under both shells.

`.env.defaults` is needed because `.env` and `.env.local` are gitignored,
so a checked-in default has nowhere else to live.

Also corrects `.env.example`, which advertised a 3000 default the repo has
not used since the port moved to 3002.

Reported by @evolv3ai in pascalorg#551, including the Windows console output and the
`set` finding.

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

Documented in SETUP.md and .env.example, referenced by zero lines of
apps/ or packages/ — there is no geocoder in this app, so the address
search it promises does not exist. Noticed while reviewing pascalorg#268.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat: add docker support

* fix(docker): persist scenes, drop root, and build with a real node

Three problems kept the image from being usable:

- Saved scenes went to $HOME/.pascal/data inside the container layer, so
  `docker compose down` silently discarded every project. Set
  PASCAL_DATA_DIR and back it with a named volume.
- The container ran as root. The base image already ships a `bun` user.
- `next build` runs under `node`, and oven/bun's `node` is a shim that
  re-execs bun. Next 16's build crashes it on both arm64 and amd64 —
  segfault on bun 1.3.14, a turbopack CommonJS error on 1.3.0. Install
  real nodejs. CI never hit this because runners have their own node.

Also pins the base image to the bun version in `packageManager` and drops
the `touch apps/editor/.env.local` workaround, which no longer matches the
path the build script reads.

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

---------

Co-authored-by: Aymeric Rabot <aymeric@pascal.app>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Docker support landed in pascalorg#268 with no mention in SETUP.md, so the only way
to find it was to notice the compose file. Records the port constraint too,
since remapping it 500s the /scenes page for a non-obvious reason.

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

Space polygons are per-level footprints and stacked storeys share a
footprint, so the upper slab's centroid sits inside the ground floor's
space polygon. Room-scope paint on a ground-floor slab therefore also
repainted the slab directly above it.

Every other branch in resolvePaintScopeTargets already gates on
space.levelId (resolveWallPaintSpace, connectedExteriorBoundaries,
wallTargetsForBoundaries); the slab branch was the one that did not.
Gate both the space lookup and the fan-out, matching how space-detection
itself defines level membership (node.parentId === levelId).

The existing slab fixture omitted parentId, which is why no test caught
this — real slabs always have one, from both the slab tool and
reconciliation.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ys reachable (pascalorg#593)

The root-category grid is `grid shrink-0` with `aspect-square` tiles over
`functionTree`, which is embedder-supplied and unbounded. Row count grows
with the taxonomy while the item list below is the only scroller, so past
about 20 categories at 5 columns the list is pushed out of the panel and
nothing can scroll it back.

Same shape as the hosted "By room" panel bug in pascalorg#562. `furnishTools` (the
other header in this panel family) is a static 6-item array, so it cannot
reach this state; this one can, because the array crosses the embedding
boundary.

Cap the grid at 40% of panel height and let it scroll independently. No
visual change at today's category counts.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* Integrate Mint asset plugin

- register the Mint panel and plugin with Pascal Editor
- add the same-origin OAuth and API proxy route
- include Mint plugin styles and pin the public v0.1.3 package

* Harden Mint plugin host integration

* Support self-hosted Mint origins
`from_brief` told the model to "call `create_project` if the user asks for a
new project" and nothing else about scene binding, so a brief against an
existing project produced mutations with no bound scene. Those apply in
memory only — nothing persists and nothing appears in the browser, and the
prompt never said so.

Name all three ways to bind (`create_project`, `list_scenes` + `load_scene`,
`create_house_from_brief`), state the consequence of skipping it, and split
the overloaded save/verify sentence out of the binding instruction. The Task
section becomes bind / build / finish so the ordering is legible rather than
one run-on line.

The registered tool description still claimed the prompt "produces a plan of
apply_patch calls"; it has produced semantic tool calls for some time.

Prompt text only — no runtime behavior changes.

Co-authored-by: Srujan Reddy <srujanreddygangireddy@gmail.com>
…alorg#597)

A custom scene material survived nothing. `materials` was absent from the
`SceneGraph` type, so every layer that rebuilds a graph field-by-field
silently omitted it: `cloneSceneGraph`/`forkSceneGraph`, the MCP bridge's
`exportJSON`/`loadJSON`, `exportSceneGraph` (which `save_scene`,
`publishLiveSceneSnapshot` and variant generation all persist through), the
SQLite read schema, and the editor's API graph schema. Reopen a scene and it
came back with default surfaces.

Nodes point at materials through `slots` values shaped `scene:mat_…`. Those
are opaque strings to the clone remapping, so material ids are carried over
unchanged — minting new ones would orphan every reference.

Two things fell out of fixing the round trip:

`loadJSON` dropped collections too, for a different reason: it applied plugin
state in a second call after `setScene`, and `setScene` resets `collections`
and `materials` to `{}` whenever they aren't in its `extra` bag. Everything
now goes in one call, which also fixes dirty-tracking for plugin-owned nodes
— `setScene` marks nodes dirty at the end, and `markDirty` skips nodes whose
plugin isn't installed yet.

The editor's echo-suppression signature omitted `materials`, so a local edit
that touched only the palette signed identically to the last remote payload
and the save was skipped — the edit was lost. The signature now defaults the
fields `setScene` always writes, so a payload that omits them (MCP live sync
sends exactly that) still matches the store that defaulted them.

Materials are validated where they enter from the network, not where they are
read back from disk. The API schema holds them to `SceneMaterial` in
`superRefine` — they carry texture URLs, and that schema is where the
`AssetUrl` allowlist is enforced — but keeps the parsed value untransformed,
since the routes persist this schema's output and `SceneMaterial` injects
defaults and strips unknown keys. The SQLite read path stays permissive:
nothing validates on write and `parseGraph` throws, so a strict read shape
would turn one odd stored value into a permanently unloadable scene.

Co-authored-by: ShiroKSH <kushidashiro@gmail.com>
…fixes (pascalorg#598)

Both pascalorg#596 and pascalorg#597 landed without a CHANGELOG line. Match the 0.6.0 style
and name the contributors, since the release notes are how credit for an
outside fix actually surfaces.
Adds the versioned standalone editor runtime, lifecycle and update tooling, release automation, tests, and documentation for @pascal-app/cli 0.1.0.
Syncs 64 upstream commits, taking upstream's core geometry and
persistence semantics while keeping the magic-cabinet extensions on the
refactored surfaces.

Upstream wins (core semantics):
- loadScene now writes explicit parentId links (Site -> Building -> Level);
  the scene authority rejects the parent/child asymmetry our version left.
- setScene's `extra` bag (pascalorg#597) carries collections/materials/installedPlugins
  in one tracked write; the follow-up setInstalledPlugins call is gone, so
  materials survive every persistence boundary.
- sqlite scene rows persist `materials`.
- graph-schema validates foreign (plugin) nodes through the BaseNode
  envelope plus the AssetUrl allowlist walk, which drops our
  @magic-cabinet/pascal-plugin/schemas import out of a server route.

Ours preserved, ported onto the new surfaces:
- SceneBridge keeps its injected per-session store; SetSceneExtra is now
  derived from UseSceneStore instead of the useScene singleton.
- HTTP transport keeps creation-identity sessions (resolveIdentity,
  localWorkspace, identity-mismatch 403) and gains upstream's GET /health.
- pascal-mcp keeps the CreationContext server factory and gains the
  PASCAL_INSTANCE_ID health envelope.
- Scene URLs take upstream's PASCAL_EDITOR_ORIGIN support but keep the
  /scene/:id route, the only one this app serves.

Also:
- @magic-cabinet/pascal-plugin pinned @pascal-app/* at ^0.9.2, so bun
  installed a stale published core beside the workspace one and the two
  type identities collided. Pinned to "*" like the rest of the workspace.
- The Mint plugin upstream added is not wired into this fork: its
  dependency, API route, transpile entry and Tailwind source are omitted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.