Files
editor/packages/mcp
bf25af6add viewer: Fix Dutch roof trim artifacts (#452)
* 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 spiral stair openings and fence handle arrows

* Implement roof trim planes and ridge vent clipping

* Fix mansard roof and ridge vent placement

* Fix mansard merged roof cutouts

* Fix Dutch roof gable overhang

* Refactor roof segment, ridge vent, and surface geometry

Remove Dutch ridge axis abstraction and rework roof edit system,
ridge vent clipping geometry, and roof surface placement.

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

* Simplify Dutch roof shape

* Add Dutch roof gable top geometry controls

* Fix Dutch roof slope material slots

* Render dutch roof tops as double-sided faces

* Add auto ridge vent toggle to roof segments

Track ridge vent auto-generation via an `autoRidgeVent` metadata flag so
geometry changes only regenerate default vents when enabled, treating
legacy segments with generated vents as auto-enabled for back-compat.
Expose a panel toggle to opt in/out per segment.

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

* Snap new walls to the floor below

Feed the walls of the level directly beneath the active one into the
draft snap pipeline as extra references, so a new wall can align with
the floor below. They share the same local XZ origin, and the list is
kept separate from the current-level walls so the measurement HUD and
wall splitting only act on the active level.

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

* Set Dutch roof shape defaults on type switch

Seed the Dutch shape parameters (waist width/height/length, top rake
thickness/length) with sensible defaults whenever a segment is created
as or switched to Dutch, so the gablet is well-formed regardless of
leftover values from the previous roof type.

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

* Use green accent for corner and endpoint snap markers

Color the corner/endpoint snap markers and the vertical cursor pillar
green across the 2D floorplan beacon, the 3D alignment guide dots, and
the wall snap beacon so snap targets read as a consistent accent.

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

* Add magnetic wall snapping to the roof tool

Snap roof draft corners onto wall corners, midpoints, crossings, and
bodies on the active level and the floor below, reusing the wall tool's
snap pipeline so the beacon and coloring match. The cursor's ground
dot/ring is hidden while a wall snap is active to avoid overlapping the
beacon glyph.

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

* Update auto-generated Next.js route types path

Regenerated next-env.d.ts now references ./.next/dev/types/routes.d.ts.

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

* Show cutaway outline while dragging roof trim

Slice an untrimmed segment volume generated from the live node instead of
the registry mesh, whose CSG rebuild lags a few frames behind the drag and
may still hold placeholder geometry — so the section outline now renders
deterministically. Use LineBasicNodeMaterial so the outline draws under the
WebGPU pipeline, and export generateRoofSegmentGeometry for the slice source.

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

* Fill and clip roof trim cutaway, gate it to active drag

Add a violet silhouette fill behind the cutaway outline, extend the
section slicing to angled diagonal/corner trims via a generic vertical
cut plane, and clip each slice to its footprint span so the infinite
plane no longer sprouts stray lines across the rest of the roof. The
cutaway now renders only while a trim handle is being dragged.

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

* Separate and extend Dutch roof end slopes

Pull the Dutch hip end slopes out of the watertight shingle shell into
their own slab wedge so they can be reshaped independently, and extend
each end slope inward up its own hip plane until the top edge meets the
gablet's inner triangle. Refactor roof-segment shape geometry into a
shared roof-segment-shape module.

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

* Render roof trim cutaway as a material-only section cut

Replace the triangle-mesh slicer with a CSG intersection of a thin slab
against the untrimmed roof shell, so the cutaway shows red only on real
material (wall + deck bands) and leaves the hollow attic empty. Add an
analytic surface-edge outline, style both solid red like a SketchUp
section, and make the cutaway persist whenever a segment is trimmed.

Keep the merged roof shell visible during trim editing (re-trimmed live
from each segment's drag override) instead of swapping in the per-segment
meshes, whose abutting end-cap faces showed as stray white planes the
commit never had. Extend each slab past free cut-line ends only — trimmed
ends clamp to the cut line — so the red section stays inside the trim box.

Re-export INTERSECTION from the viewer CSG surface for the editor.

* Outline roof cutaway by fill silhouette, restyle to destructive red

Derive the section-cut outline from the fill geometry's edges
(EdgesGeometry) so it traces the real cut shape — wall/deck band
boundaries and the hollow-attic edge — instead of just the top surface
line. Drop the fill to 85% opacity and recolor both fill and outline to
the app's destructive red, matching the delete/destructive UI.

* Include roof accessories in trim clipping and red cutaway

Roof accessories (chimney, vents, skylight, dormer, gutter, downspout,
solar-panel, cupola) now slice at the trim plane like the roof shell and
appear in the red section-cut while dragging a trim handle:

- Export clipGeometryBySegmentTrim from the viewer as a reusable
  segment-local trim-clip primitive.
- Add a shared useSegmentTrimClippedGeometry hook + TrimClippedMesh
  wrapper (nodes) that slice accessory geometry by the host segment's
  live trim override, so the cut tracks the drag.
- Wire the clip into all 11 accessory renderers, including skylight
  glass panes and dormer window glass/frame/sill.
- Feed every hosted accessory mesh into the editor's red cutaway, welding
  triangle-soup geometry (e.g. ridge vent) so CSG INTERSECTION yields a
  cross-section.
- Register skylight in the scene-graph tree-node map so it shows in the
  outliner when placed on a roof.

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

* Add smooth spline fences with editable curve handles

Fences can now be drawn as one continuous Catmull-Rom/Bezier curve via an
optional `path` (+ per-point `tangents`), selectable in a Straight/Curved
mode toggle. Selected spline fences expose draggable control-point dots
(hexagon) and symmetric tangent handles (circle) joined by a violet line,
editable in both 2D plan and 3D. Side-move arrows are dropped for splines.

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

* Fix dutch roof ridge vent handling

* Fix Dutch ridge vent placement and support

* Fix Dutch roof trim artifacts

* Fix Dutch roof trim preview geometry

* Tag roof trim overlay meshes with EDITOR_LAYER

Child meshes relied on a parent group's layer, which three.js does not
propagate, so the trim section/rail/plane overlays rendered on the scene
layer — getting inked/SSGI-darkened and leaking into thumbnail exports.

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

* Apply Biome cleanup

* fix(core): address Dutch roof review feedback

* chore: apply biome check cleanup

* fix(core): relax Dutch roof surface helper input

* fix

* Fix biome checks and dev verification

* fixes

* Remove unsupported Biome noShadow override

* Improve roof interactions and fence editing

* Fix fence drag and ridge vent default handling

* editor: drop wall-snap debug log, gate curved-fence finish hint on draft start

Remove the leftover TEMP DIAGNOSTIC console.log in the wall tool's onMove
hot path.

Curved fences commit on a closing gesture (double-click / Enter) rather than
per-click, so surface a 'Finish curve' hint in the fence HUD — but only once a
point has been placed and a curve is actually in flight. The draft point count
is published from SplineFenceDraft into a small ephemeral editor store
(useFenceCurveDraft) that the contextual helper reads, mirroring the existing
useSegmentDraftChain pattern.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Wassim SAMAD <wass08@gmail.com>
2026-06-30 16:41:13 -04:00
..

@pascal-app/mcp

Model Context Protocol server for the Pascal 3D editor. Drives the @pascal-app/core scene graph from any MCP-compatible AI host.

The server runs headlessly in Bun with no browser, WebGPU, React, or external database service. It exposes the same scene mutations used by the editor UI (create walls, place items, cut openings, undo, etc.) as MCP tools, resources, and prompts.

Install

bun add @pascal-app/mcp

@pascal-app/core is a peer dependency; Bun workspaces resolve it automatically. The MCP CLI is intended to run with Bun. When the storage package is consumed by the Next.js editor server, it opens the same local database through Node's built-in SQLite driver.

Quick start

Launch the server over stdio in one line:

bunx pascal-mcp

Load an initial scene from disk:

pascal-mcp --stdio --scene ./my-scene.json

Expose it over loopback HTTP:

pascal-mcp --http --port 8787

Binding a non-loopback host requires a bearer token:

PASCAL_MCP_HTTP_TOKEN="$(openssl rand -hex 32)" \
  pascal-mcp --http --host 0.0.0.0 --port 8787 --cors-origin https://editor.example

Local scene storage

Scenes saved through MCP are stored in a local SQLite database:

~/.pascal/data/pascal.db

Set PASCAL_DATA_DIR when you want the MCP server and the running editor to share a different directory, or PASCAL_DB_PATH when you need an exact database file path. The store uses WAL mode and transactional version checks so separate local processes can save and open the same scene database.

During workspace development, run both sides with the same data directory:

# Terminal 1: run the editor
PASCAL_DATA_DIR="$HOME/.pascal/data" bun run dev

# Terminal 2 or an MCP host: run the server
PASCAL_DATA_DIR="$HOME/.pascal/data" bun packages/mcp/dist/bin/pascal-mcp.js

Live editor updates

When the editor and MCP server share the same PASCAL_DATA_DIR, MCP mutations against a loaded saved scene are persisted to SQLite and recorded in a local scene_events stream. The editor page subscribes to that stream at /api/scenes/:id/events with server-sent events, so an open browser tab can apply scene graph snapshots as the agent edits the scene.

The flow is intentionally local and lightweight:

  1. Open or create a scene in the editor so it is saved in the local database.
  2. Load that scene through MCP with load_scene.
  3. Run MCP mutation tools such as create_room, add_door, furnish_room, create_wall, place_item, or set_zone.

Each mutation version-checks the saved scene before writing. If the browser or another MCP process saved a newer version first, the MCP tool returns live_sync_version_conflict; reload the scene with load_scene before continuing.

Claude Desktop config

Edit ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%\Claude\claude_desktop_config.json (Windows):

{
  "mcpServers": {
    "pascal": {
      "command": "bunx",
      "args": ["pascal-mcp"],
      "env": {
        "PASCAL_DATA_DIR": "/Users/you/.pascal/data"
      }
    }
  }
}

If bunx is not on your PATH, point command at the absolute path to bun and pass the built dist/bin/pascal-mcp.js file as the first arg.

Claude Code config

Via the CLI:

claude mcp add pascal bunx pascal-mcp

Or add to .mcp.json at the repo root:

{
  "mcpServers": {
    "pascal": {
      "command": "bunx",
      "args": ["pascal-mcp"],
      "env": {
        "PASCAL_DATA_DIR": "/Users/you/.pascal/data"
      }
    }
  }
}

For local workspace testing before publish, build first and point Claude Code at the built binary:

{
  "mcpServers": {
    "pascal": {
      "command": "bun",
      "args": ["/absolute/path/to/editor/packages/mcp/dist/bin/pascal-mcp.js"],
      "env": {
        "PASCAL_DATA_DIR": "/Users/you/.pascal/data"
      }
    }
  }
}

Codex CLI config

Via the CLI:

codex mcp add pascal --env PASCAL_DATA_DIR="$HOME/.pascal/data" -- bunx pascal-mcp

For local workspace testing before publish:

bun run --cwd packages/mcp build
codex mcp add pascal-dev \
  --env PASCAL_DATA_DIR="$HOME/.pascal/data" \
  -- bun "$PWD/packages/mcp/dist/bin/pascal-mcp.js"

This writes an entry like this to ~/.codex/config.toml:

[mcp_servers.pascal-dev]
command = "bun"
args = ["/absolute/path/to/editor/packages/mcp/dist/bin/pascal-mcp.js"]

[mcp_servers.pascal-dev.env]
PASCAL_DATA_DIR = "/Users/you/.pascal/data"

Cursor config

In Cursor settings (settings.json):

{
  "mcp.servers": {
    "pascal": {
      "command": "bunx",
      "args": ["pascal-mcp"],
      "env": {
        "PASCAL_DATA_DIR": "/Users/you/.pascal/data"
      }
    }
  }
}

Programmatic use

Embed the server in your own Bun process using the in-memory transport. The example below runs a full client/server pair inside a single script — useful for agent frameworks and tests.

import { createPascalMcpServer, SceneBridge } from '@pascal-app/mcp'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'

const bridge = new SceneBridge()
bridge.loadDefault()
const server = createPascalMcpServer({ bridge })

const [srvT, cliT] = InMemoryTransport.createLinkedPair()
const client = new Client({ name: 'my-agent', version: '0.1.0' })
await Promise.all([server.connect(srvT), client.connect(cliT)])

const tools = await client.listTools()
console.log('available tools:', tools.tools.map((t) => t.name))

const scene = await client.callTool({ name: 'get_scene', arguments: {} })
console.log(scene)

See examples/embed-in-agent.ts for a compilable version.

Coordinate conventions

Pascal is a right-handed scene where X and Z form the ground plane and Y is up. Lengths are in metres; rotations are radians, stored as Euler [x, y, z] tuples.

Plan → world. Every 2-D point you pass is a level/building-local ground-plane coordinate [x, z] — this includes wall.start / wall.end and the polygon / holes arrays of slab, zone, and ceiling. With the default identity building transform, it appears in world space as:

[x, z]  →  (x, y, z)      // the 2nd component is world Z (depth), not "up"

There is no sign flip in the stored convention: tooling consumes the second component as world Z directly. The vertical y starts from the owning level's stacked height as computed by the level system from accumulated level heights, plus the element's own height; slabs additionally carry an absolute elevation.

Heads-up when you compute coordinates outside the editor. Pascal's viewports apply their own rotations on top of the world axes: the 2-D plan panel wraps its content in a 90° rotation (FLOORPLAN_VIEW_ROTATION_DEG), and the 3-D "top-down" snap preserves the camera's current azimuth, so when invoked from the iso default position, world and screen axes are offset by ~45° until you orbit to an axis-aligned view. So a layout authored as if "Y = north, viewed top-down" — common in land surveys, north-up site plans, and 2-D plotting libraries — will arrive rotated relative to its source when viewed in Pascal (and possibly further reflected, depending on which viewport and camera state you're in). The editor's own 2-D and 3-D tools are internally consistent with their stored coordinates, so this only affects geometry authored programmatically. To verify orientation before trusting externally-computed coordinates, place a scaled guide image at known anchor points and check alignment; apply whatever rotation (or reflection) your authoring side needs to match.

A worked demonstration of all of this — axis-aligned baseline, the rotated 30° example below, and a paired "page-intent vs world-result" L for the external-coordinate gotcha — lives in examples/coordinate-conventions-demo.md and examples/coordinate-conventions-demo.json. Load the JSON with pascal-mcp --stdio --scene examples/coordinate-conventions-demo.json.

Example — a 6 × 4 m slab rotated 30° about its first corner (coordinates rounded to 3 dp; sides ≈ 6 m / 4 m; not axis-aligned, so the mapping is actually exercised):

{
  "op": "create",
  "parentId": "<levelId>",
  "node": {
    "type": "slab",
    "elevation": 0.0,
    "polygon": [[0, 0], [5.196, 3.0], [3.196, 6.464], [-2.0, 3.464]]
  }
}

This lands flat on the ground (Y = 0), about 6 m along a heading 30° off the +X axis and 4 m along its perpendicular — i.e. occupying world (x, z) directly.

One separate gotcha: wall-attached coordinates are wall-local, not plan coordinates. Stored door/window position[0], and place_item position[0] when the target is a wall, are metres along the wall; wall-attached rotations are wall-local too.

Tools

All tools validate their inputs and outputs with Zod. Mutation tools are captured by Zundo's temporal middleware as a single undoable step.

Name Purpose Key input Output
get_scene Return the full scene graph. { nodes, rootNodeIds, collections }
get_node Fetch a node by id. { id } the node, or InvalidParams if not found
describe_node Node summary with ancestry, children count and properties. { id } { id, type, parentId, ancestry[], childrenCount, properties, description }
find_nodes Filter nodes by type / parent / zone / level. { type?, parentId?, zoneId?, levelId? } { nodes: AnyNode[] }
list_levels List levels with ids, floor indices, parent ids and child counts. { activeSceneId, levels[] }
get_level_summary Compact summary of one level with counts, wall/opening lists, zones, slabs, ceilings and items. { levelId? } { levelId, counts, walls, zones, items, slabs, ceilings }
get_walls Walls on a level with length and child doors/windows. { levelId? } { levelId, walls[] }
get_zones Room/zone polygons with approximate areas and bounds. { levelId? } { levelId, zones[] }
measure Distance between two nodes; area when applicable. { fromId, toId } { distanceMeters, areaSqMeters?, units: 'meters' }
search_assets Search the built-in MCP item catalog. { query, category? } { results, total }
create_story_shell Create one level-owned story shell from a footprint: perimeter walls plus optional slab and ceiling. Use once per story. { levelId, footprint, wallHeight?, wallThickness?, createSlab?, createCeiling? } { wallIds, slabId, ceilingId, createdIds }
create_stair_between_levels Create a straight stair and one rectangular manual opening in the destination slab/source ceiling, with auto-opening disabled. { fromLevelId, toLevelId, position, width?, runLength?, totalRise? } { stairId, stairSegmentId, openingPolygon }
create_roof Create a roof container and one roof segment. By default creates a dedicated roof level above the reference occupied level for solo/exploded views. { levelId, width, depth, roofType?, roofHeight?, roofLevelId?, useDedicatedRoofLevel? } { roofLevelId, createdRoofLevelId, roofId, roofSegmentId }
create_room Create a zone, slab, ceiling, and walls from a polygon. { levelId, name, polygon, color?, wallHeight?, wallThickness? } { zoneId, slabId, ceilingId, wallIds, areaSqMeters }
add_door Add a door to a wall using parametric placement. { wallId, t, width?, height?, hingesSide?, swingDirection? } { doorId, localX }
add_window Add a window to a wall using parametric placement and sill height. { wallId, t, width?, height?, sillHeight? } { windowId, localX, sillHeight }
furnish_room Place realistic furniture for a room type inside a polygon. { levelId, roomType, polygon, doorWallIndex? } { placed, itemIds, skipped }
apply_patch Batched create/update/delete/move, validated and dry-run before commit. { patches: Patch[] } { applied: number }
create_level Add a new level to a building. { buildingId, elevation, height, label? } { levelId }
create_wall Add a wall to a level. { levelId, start, end, thickness?, height? } { wallId }
place_item Place a catalog item on a level/slab/zone, ceiling, wall, or site. Slab/zone targets resolve to the parent level so floor items render and validate. { catalogItemId, targetNodeId, position, rotation? } { itemId, status }
cut_opening Cut a door or window opening into a wall. position is 0..1 along the wall and is stored as wall-local meters. { wallId, type: 'door' | 'window', position, width, height } { openingId }
set_zone Create a zone/room polygon on a level. { levelId, polygon, label, properties? } { zoneId }
duplicate_level Clone a level and all of its descendants. { levelId } { newLevelId, newNodeIds[] }
delete_node Delete a node; cascades when cascade: true. { id, cascade? } { deletedIds: [] }
undo Step back through temporal history. { steps? } { undone: number }
redo Step forward through temporal history. { steps? } { redone: number }
export_json Serialize the scene graph as JSON. { pretty? } { json: string }
export_glb Stubbed: GLB export requires the browser renderer. throws not_implemented
validate_scene Zod-validate every node and parent-child integrity. { valid, errors: { nodeId, path, message }[] }
verify_scene High-level layout check with validation status, per-level counts, empty levels and practical issues. { valid, levels[], issues, hasIssues }
check_collisions Find overlapping items and out-of-bounds placements. { levelId? } { collisions: { aId, bId, kind }[] }
analyze_floorplan_image Vision tool: extract walls, rooms, and approximate dimensions from a floorplan image. { image, scaleHint? } { walls, rooms, approximateDimensions, confidence }
analyze_room_photo Vision tool: extract approximate dimensions and fixtures from a room photo. { image } { approximateDimensions, identifiedFixtures, identifiedWindows }

The vision tools require the MCP host to support the sampling capability (createMessage). Hosts that don't will see a structured sampling_unavailable error.

Resources

URI MIME Purpose
pascal://scene/current application/json Full { nodes, rootNodeIds, collections } snapshot.
pascal://scene/current/summary text/markdown Human-readable summary with node counts, bounding box, and level areas.
pascal://agent/guide text/markdown MCP-first construction workflow, scene invariants, and tool preferences for agents.
pascal://catalog/items application/json Dependency-free built-in catalog subset for common residential furniture and fixtures.
pascal://constraints/{levelId} application/json Slab footprints and wall polygons for the given level — useful as planner context.

Prompts

Name Args Purpose
from_brief { brief: string, constraints?: string } Guided workflow for turning a prose brief (e.g. "2-bed apartment in 80 m²") into an incremental sequence of apply_patch calls starting from an empty site.
iterate_on_feedback { feedback: string } Minimal-diff instructions: examine the current scene, then propose the smallest patch set that satisfies the feedback.
renovation_from_photos { currentPhotos: string[], referencePhotos: string[], goals: string } Chains the vision tools with the scene mutation tools to produce a renovation plan grounded in photos.

Limitations

  • export_glb returns not_implemented. GLB export depends on the Three.js renderer and isn't reachable headlessly without a large additional effort.
  • Vision tools require MCP host sampling support. Claude Desktop supports this; some MCP clients don't.
  • The built-in MCP catalog is intentionally small. Host applications can expose their own richer catalog through additional tools/resources without requiring the MCP package to depend on the editor UI bundle.
  • Systems (wall mitering, slab triangulation, CSG cutouts, roof / stair generation) run inside React hooks in the editor. Headless mode doesn't regenerate derived geometry — but all node data remains fully manipulable. Consumers that need rendered geometry run @pascal-app/viewer in a browser host.
  • Core's loadAssetUrl / saveAsset are browser-only; items that reference asset://<id> URLs aren't resolvable in Node. Supply absolute URLs or data: URLs for item assets if you need them usable outside the browser.
  • dirtyNodes accumulates in headless mode because no renderer consumes it. Call bridge.flushDirty() if observability matters to your consumer.

Development

bun install
bun run --cwd packages/mcp build
bun test

Smoke-test the stdio binary end-to-end:

bun run --cwd packages/mcp smoke

License

MIT