From 6541c74e166665319bc918a34f2f87ad2f370da9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 16:53:08 +0000 Subject: [PATCH 1/5] docs(mcp): tool reference for barrel and glazed roofs, roof seating, notation, view sizes and plugin tools --- packages/mcp/CHANGELOG.md | 45 ++++++++++++++++++- packages/mcp/README.md | 94 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 131 insertions(+), 8 deletions(-) diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index a195830654..b8814e3f66 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -7,8 +7,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `registerOperationTool(server, operations, tool, catalog?)` from + `@pascal-app/mcp/tools/shared-tools`: the shared tools' runner, so a host + registers a plugin's `'operation'` tools the same way, with its own + annotations ([#1026](https://github.com/pascalorg/editor/pull/1026)). +- `createSceneOperations({ activeLevelId })`: the floor the host's person is + viewing, which the shared tools, `add_object`, `add_column` and the scripted + openings read when a call names no level; without it, the lowest storey + ([#1083](https://github.com/pascalorg/editor/pull/1083)). +- `add_object`, `add_window`, `add_door` and `add_column` take + `language: 'bpy'`; a local server runs Blender modules in the binary + `PASCAL_BLENDER` names ([#1033](https://github.com/pascalorg/editor/pull/1033)). +- `move_roofs_to_roof_level` moves the roofs a storey holds on its own walls to + the roof level ([#1047](https://github.com/pascalorg/editor/pull/1047)). +- The agent guide points a hosted server's agent at the project's other chats, + files and members ([#1035](https://github.com/pascalorg/editor/pull/1035)). + ### Changed +- `get_site` gives the street's frontage and depth, each buildable edge as a + front, the ground and the usual footprint range, and reads a lot by `site` + with no building yet ([#1044](https://github.com/pascalorg/editor/pull/1044), + [#1080](https://github.com/pascalorg/editor/pull/1080)). +- Lengths and angles read text in any unit, and `create_roof`'s `pitch` rise + over run (`"6:12"`) ([#1079](https://github.com/pascalorg/editor/pull/1079)). +- `paint` reaches every part with surfaces and `search_materials` browses the + library ([#1034](https://github.com/pascalorg/editor/pull/1034), + [#1076](https://github.com/pascalorg/editor/pull/1076)). +- `view_scene` takes street-relative sides, `camera: 'current'` (refused here, + `no_current_camera`), `lighting`, `maxPx` and `interior`, and renders + 1920 × 1200 ([#1034](https://github.com/pascalorg/editor/pull/1034), + [#1051](https://github.com/pascalorg/editor/pull/1051), + [#1075](https://github.com/pascalorg/editor/pull/1075)). +- `create_stair` builds L and U stairs, railings and a flight to no level + ([#1073](https://github.com/pascalorg/editor/pull/1073)). +- `create_roof` builds barrel and glazed roofs (`roofSystem`, `glazing`, + `barrelRise`, `barrelSegments`) ([#1055](https://github.com/pascalorg/editor/pull/1055)). +- `apply_patch` seats a roof on the level above the walls that carry it, with a + note, and refuses `roof_level`, `roof_level_holds_storey` and + `roof_level_on_top` ([#1039](https://github.com/pascalorg/editor/pull/1039), + [#1057](https://github.com/pascalorg/editor/pull/1057)). + - `verify_scene` is the one scene check: it lists every node field its schema rejects (`schema_invalid`, with `nodeId` and `path`), a node kind no schema knows once per kind (`schema_unknown_kind`, info), a hidden site @@ -31,7 +72,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `validate_scene` retired into `verify_scene`, `find_nodes` into `find_by_type` and `describe_node` into `get_node`. A call to one returns the - error `tool_retired` with its replacement and how to use it. + error `tool_retired` with its replacement and how to use it + ([#1081](https://github.com/pascalorg/editor/pull/1081), + [#1084](https://github.com/pascalorg/editor/pull/1084)). ### Fixed diff --git a/packages/mcp/README.md b/packages/mcp/README.md index b478b864f6..62eb48c125 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -101,8 +101,8 @@ editor reads and writes the same database-side store. Local MCP compilation is opt-in with `PASCAL_SERVER_SCRIPT_COMPILE=1`. It runs in a separate process without inherited environment variables, with a deadline; this is not a sandbox for untrusted code. Blender (`bpy`) modules run in the Blender binary `PASCAL_BLENDER` names -(`blender -b`), with the same caveat. `get_source` can read saved scripts without enabling -compilation. +(`blender -b`), with the same caveat; [Blender geometry scripts](../../wiki/blender-geometry-scripts.md) +walks through one. `get_source` can read saved scripts without enabling compilation. ## Live editor updates @@ -253,6 +253,39 @@ console.log(scene) See [`examples/embed-in-agent.ts`](./examples/embed-in-agent.ts) for a compilable version. +### Plugin tools and the person's floor + +A plugin's `'operation'` tools register with the same runner as the shared +tools: it parses the input, applies the outcome as one history step and answers +with `achieved` and the refusals. The annotations are the host's, never read +from the plugin's contract. + +```ts +import { createSceneOperations } from '@pascal-app/mcp/operations' +import { registerOperationTool } from '@pascal-app/mcp/tools/shared-tools' +import { signageAgentTools } from 'acme-signage/agent-tools' +import { signageAgentOperations } from 'acme-signage/agent-operations' + +const operations = createSceneOperations({ + bridge, + // The floor the person is viewing: a tool call that names no level reads it. + // Leave it out on a host with no view, and the lowest storey is read. + activeLevelId: () => myHost.viewedLevelId(), +}) + +for (const contract of signageAgentTools.tools) { + if (contract.runsIn !== 'operation') continue + registerOperationTool(server, operations, { + contract, + operation: signageAgentOperations[contract.name], + annotations: { readOnlyHint: false, destructiveHint: false }, + }) +} +``` + +The contract shapes, versions and trust rules are in +[plugin authoring](../../wiki/architecture/plugin-authoring.md#agent-tools-and-chat-extensions). + ## Coordinate conventions Pascal is a **right-handed** scene where **X and Z form the ground plane and Y @@ -341,6 +374,19 @@ tallest wall or ceiling, and for an empty level the default storey height, 2.5 m `apply_patch` says so in `notes`, one line per level filled. `add_level` takes an optional `height` and uses the same default. +Every length and angle a shared tool takes reads one notation: a bare number is +in the unit the field states (metres, degrees, radians), and text carries its +own unit, inches, feet and millimetres included. A roof pitch is degrees or rise +over run: + +```json +{ "wallHeight": "9 ft", "overhang": "18 in", "pitch": "6:12" } +``` + +`"26.6"`, `"26.6°"`, `"0.46 rad"`, `"6/12"`, `"6 in 12"` and `"6 on 12"` are +read too. Minutes and seconds are refused (`'6"'` is a builder's inches), and so +is a "pitch" fraction like `"1/4 pitch"`, which is rise over span. + | Name | Purpose | Key input | Output | | --- | --- | --- | --- | | `get_scene` | Return the full scene graph. | — | `{ nodes, rootNodeIds, collections }` | @@ -353,15 +399,15 @@ an optional `height` and uses the same default. | `get_site` | The site a building stands on, in its level coordinates (a lot with no building yet, in site coordinates): the lot and its area with the footprint a house usually covers (30 to 45%), the buildable area inside the setbacks with its centre, area, each edge's frontage and depth and its front edge, the street side with the lot's frontage and depth, north, the ground (sloped, read flat or not read), address, parcel and zoning, whether the lot is the new-project placeholder, and the footprint's fit and yards. | `{ building?, site? }` | `{ siteId, buildingId, pose, lot, placeholder, buildable, street, ground, northDeg, address, parcel, zone, footprint, others, summary }`; refusals `no_site`, `building_required`, `site_not_found`, `not_a_site`, `building_not_on_site` | | `place_building` | Stand a building on its site by moving the building node only (walls keep their level coordinates): its footprint, the outline about to be drawn or the walls standing, centred in the buildable area or at the yards given; a house standing keeps its rotation unless `rotationDeg` is given, one with no walls yet is squared to the street. | `{ building?, outline?, yards?: { front?, rear?, left?, right? }, rotationDeg? }` | `{ buildingId, position, rotationDeg, moved, footprint, yards, fit, overhang, movedWithIt?, message }`; refusals `no_site`, `needs_footprint`, `other_building_on_site`, `no_street`, `yards_conflict`, `yards_infeasible`, `building_required` | | `measure` | Distance between two nodes' world-space reference points (hosted doors, windows and items resolved through their host; every node kind); area when applicable. | `{ fromId, toId }` | `{ distanceMeters, fromPoint?, toPoint?, areaSqMeters?, units: 'meters' }` | -| `view_scene` | Look at the finished building, every storey and the roof, from a viewpoint the agent picks (a compass side, a side as seen from the house's street, a street-height eye, a photo's camera, square-on, from above), in the scene's own light or a photo's, and get the picture back, with the photo's crop beside it when a region is given. `interior: { levelId }` looks inside one floor: the other floors and its roofs and ceilings left out, everything on it (walls, openings, furniture) cut at 0.65 of the storey's height, for the picture only. The server has no renderer: the host's `sceneViews` renders it (the hosted app, through the user's open editor tab). Read-only. | `{ target?, interior?, from?, elevation?, eyeHeight?, position?, fov?, projection?, camera?, lighting?, maxPx?, photo? }` | image content plus `{ status, camera, size, tab, capturedAt }`; refusals `view_unavailable`, `no_project`, `which_building` (a street side, no target, buildings on several sites), `no_current_camera` (`camera: 'current'`: the server has no view of the person's), `photo_crop_unavailable`, `interior_level_invalid`, `interior_hidden`, `interior_transform_unsupported`, `interior_target_elsewhere`; on the hosted server `editor_tab_required`, `editor_tab_hidden`, `editor_tab_busy` (a tab open but busy or still loading, no link: wait a few seconds and call again, do not ask the user), `editor_tab_timeout` (a tab claimed the view and then stopped answering); what the refusal says about opening the editor follows the person's "Agents may open the editor for me" setting (ask first, allow, never) | +| `view_scene` | Look at the finished building, every storey and the roof, from a viewpoint the agent picks (a compass side, a side as seen from the house's street, a street-height eye, a photo's camera, square-on, from above), in the scene's own light or a photo's, and get the picture back, with the photo's crop beside it when a region is given. `interior: { levelId }` looks inside one floor: the other floors and its roofs and ceilings left out, everything on it (walls, openings, furniture) cut at 0.65 of the storey's height, for the picture only. Pictures are 1920 × 1200 unless `maxPx` (64 to 1920) asks for a smaller longest side. The server has no renderer: the host's `sceneViews` renders it (the hosted app, through the user's open editor tab). Read-only. | `{ target?, interior?, from?, elevation?, eyeHeight?, position?, fov?, projection?, camera?, lighting?, maxPx?, photo? }` | image content plus `{ status, camera, size, tab, capturedAt }`; refusals `view_unavailable`, `no_project`, `which_building` (a street side, no target, buildings on several sites), `no_current_camera` (`camera: 'current'`: the server has no view of the person's), `camera_and_viewpoint` (a camera with a viewpoint of the agent's own), `photo_crop_unavailable`, `interior_level_invalid`, `interior_hidden`, `interior_transform_unsupported`, `interior_target_elsewhere`; on the hosted server `editor_tab_required`, `editor_tab_hidden`, `editor_tab_busy` (a tab open but busy or still loading, no link: wait a few seconds and call again, do not ask the user), `editor_tab_timeout` (a tab claimed the view and then stopped answering); what the refusal says about opening the editor follows the person's "Agents may open the editor for me" setting (ask first, allow, never) | | `search_assets` | Search the host's item library (the built-in list on a standalone server), several queries in one call; a query matches name, id, category or tags. A query that finds nothing gets a hint to build the item with `add_object`. | `{ queries: [{ query, category? }] }` | `{ groups: [{ query, total, results[] }], total, hint? }`; refusal `no_catalog` | | `search_materials` | Search or browse the material library, several queries in one call: words match as the library writes them (timber is wood, render is stucco), shades and surfaces; a query without words lists a kind, a surface or the whole library by name. A search lists 12 at a time and a browse 150, a long one saying how many more (`more`) and the offset that lists them (`next`). Reads Pascal's library and the materials the host registered (the editor registers its library; this server registers none). Read-only. | `{ queries: [{ query?, kind?, surface?, offset? }] }` | `{ groups: [{ query?, kind?, surface?, offset?, results: [{ ref, label, kind, surfaces, color? }], missing?, more?, next? }] }` | -| `paint` | Give walls, slabs, ceilings, roofs and every other part with surfaces (columns, fences, doors, windows, stairs, blocks, cabinets, shelves…) a finish in one call, as the editor's Paint does: a colour (the nearest library colour), a `library:` or `scene:` ref, a native material kept as a scene material (reused when an equal one exists), or erase. `role` picks the surface (a wall's `exterior`/`interior` faces, by its rooms or else its stored sides, refused when rooms stand on only part of a face, a slot by name, a roof's `top`/`edge`/`wall`, or its `fascia`, the fascia and rake boards, held by every segment); a roof shows one finish per surface, so a segment's finish goes on its roof and a roof's clears its segments' own; a wall face is painted whole, over its room's wall finish, its painted parts keeping theirs (named in `note`). | `{ targets[], role?, color?, material?, materialName?, erase? }` (one finish) | `{ ok, painted[], finish, materialId?, material?, createdMaterial?, note?, achieved }`; refusals `target_not_found`, `unknown_role`, `side_unknown`, `unknown_material`, `materials_unavailable`, `not_paintable` | +| `paint` | Give walls, slabs, ceilings, roofs and every other part with surfaces (columns, fences, doors, windows, stairs, blocks, cabinets, shelves…) a finish in one call, as the editor's Paint does: a colour (the nearest library colour), a `library:` or `scene:` ref, a native material kept as a scene material (reused when an equal one exists), or erase. `role` picks the surface (a wall's `exterior`/`interior` faces, by its rooms or else its stored sides, refused when rooms stand on only part of a face, a slot by name, a roof's `top`/`edge`/`wall`, or its `fascia`, the fascia and rake boards, held by every segment; a glazed roof's `glazing-frame`, `glazing-glass` or `glazing-solid`, and without a role its frame and solid panels, the glass kept); a roof shows one finish per surface, so a segment's finish goes on its roof and a roof's clears its segments' own; a wall face is painted whole, over its room's wall finish, its painted parts keeping theirs (named in `note`). | `{ targets[], role?, color?, material?, materialName?, erase? }` (one finish) | `{ ok, painted[], finish, materialId?, material?, createdMaterial?, note?, achieved }`; refusals `target_not_found`, `unknown_role`, `side_unknown`, `unknown_material`, `materials_unavailable`, `not_paintable` | | `create_story_shell` | Create one level-owned story shell from a footprint: the perimeter walls. The floor plate and ceiling are derived from the enclosed rooms; `createSlab` / `createCeiling` / `slabElevation` are recorded as room intent. Use once per story. | `{ levelId, footprint, wallHeight?, wallThickness?, createSlab?, createCeiling? }` | `{ wallIds, zoneIds, slabId, ceilingId, createdIds }` | | `create_stair` | Create a straight, L or U stair as the editor's stair tool does: from a level to the next one above (made when there is none), owning the floor openings it cuts, or to no level (`toLevelId: null`). `(x, z)` is the middle of the bottom riser, `rotation` in degrees (0 climbs toward +Z). | `{ x, z, levelId?, toLevelId?, rotation?, layout?, turn?, lowerSteps?, width?, length?, height?, steps?, railingMode?, railingStyle?, railingMaterial? }` | `{ stairId, segmentIds, upperLevelId, createdUpperLevel, layout, stepCount, rise, length, footprint, arrival, slabHoleCut, openingIds? }`; refusals `roof_level`, `not_above`, `level_not_found`, `slab_not_found` / `ceiling_not_found`, `slab_not_on_level` / `ceiling_not_on_level`, `stair_layout`, `stair_layout_required`, `no_level_above` | | `measure_stair` | Read actual risers, going at the walking line, slope, uniformity, arrival, headroom against floors/ceilings/stair bodies, and design-target diagnostics. | `{ stairId, available?: { width, length } }` | `{ measurements, layouts }` | | `fit_stair` | Fit uniform risers or replace the chain with a straight/L/U preset with width-sized landings or quarter-turn winders in one undoable edit. | `{ stairId, fitRun?, targets?, layout?, turn?, width?, landingDepth?, turningStrategy?, innerGap?, walkingLineOffset?, division? }` | `{ stairId, measurements }` | -| `create_roof` | Cap the top storey with a roof over its footprint (its walls' outline, else its floor plates, else its rooms), one segment per rectangle, or over a rectangle of your own (`width`, `depth`, `center`). The roof goes on the building's roof level above the storey (reused, the empty level directly above when it is the only one, else made and named Roof) and stands on the storey's wall tops; the hand roof tool follows the same rule. | `{ levelId? (or level), roofType?, pitch? (degrees, or rise over run: "6:12"), overhang? (the eaves' reach past the walls in plan), wallHeight?, width?, depth?, center?, materialPreset?, fasciaHeight?, fasciaMaterialPreset?, name? }` | `{ roofId, roofLevelId, createdRoofLevelId, coveredLevelId, segmentIds, roofType, achieved }`; refusals `storey_above`, `roof_exists`, `building_required` (several buildings, no level named), `storey_without_building`, `conflicting_level`, `no_levels`, `no_rooms`, `rectangle_incomplete`, `unknown_material` (the roof's or the fascia's finish) | +| `create_roof` | Cap the top storey with a roof over its footprint (its walls' outline, else its floor plates, else its rooms), one segment per rectangle, or over a rectangle of your own (`width`, `depth`, `center`). The roof goes on the building's roof level above the storey (reused, the empty level directly above when it is the only one, else made and named Roof) and stands on the storey's wall tops; the hand roof tool follows the same rule. `roofType: "barrel"` arches each segment (`depth` is the span, `barrelRise` at most half of it, `barrelSegments` facets from 4 to 32); `roofSystem: "glazed"` builds a gable, straight shed or barrel roof as panes and framing, set by `glazing` (see [glazed roofs](../../wiki/glazed-roofs.md)). | `{ levelId? (or level), roofType?, roofSystem? ('standard' \| 'glazed'), glazing?, barrelRise?, barrelSegments?, pitch? (degrees, or rise over run: "6:12"), overhang? (the eaves' reach past the walls in plan), wallHeight?, wallThickness?, width?, depth?, center?, materialPreset?, fasciaHeight?, fasciaMaterialPreset?, name? }` | `{ roofId, roofLevelId, createdRoofLevelId, coveredLevelId, segmentIds, roofType, achieved }`; refusals `storey_above`, `roof_exists`, `building_required` (several buildings, no level named), `storey_without_building`, `conflicting_level`, `no_levels`, `no_rooms`, `rectangle_incomplete`, `unknown_material` (the roof's or the fascia's finish) | | `move_roofs_to_roof_level` | Move the roofs a storey holds on its own walls to the level the roof rule gives them (the building's roof level over the top storey, made when there is none; the storey above a lower one), as a roof's panel does by hand. Each roof stands where it stood, segments, finishes and id kept. For projects built before the roof level, when `verify_scene` reports `roof_mixed_in_storey`. | `{ levelId }` | `{ roofIds, roofLevelId, createdRoofLevelId, achieved }`; refusals `level_not_found`, `no_roof_on_walls` | | `create_room` | Create a room from a polygon: one wall per edge (an edge a wall already runs along reuses it) and the room zone; its doors and windows can be declared in the same call by edge (`wallIndex`) and `t`, each placed with `add_door`'s and `add_window`'s rules or listed in `skippedOpenings`. The floor plate and ceiling are derived, never authored. `outdoor: true` makes a terrace: separators (the editor's Separator: a room boundary with no wall) where no wall runs, no walls of its own, no ceiling. Where every edge of the polygon already runs along a wall (nothing to build) and the polygon fits the room those walls make, the call names that room instead of stacking a second zone on it, and `zoneId` is that room's. If that room has a name of its own (not a number the editor gave it: any name but the editor's "Room N", whoever gave it) the call is refused (`room_named_by_person`, worded with the room's name): pass `rename: true` **only when the person asked for the rename; otherwise ask them**. A room is never renamed unasked, and the same name over it says there is nothing to create. A terrace (`outdoor: true`) drawn over a fully walled courtyard names it outdoor. The refusal is agent-only: the editor's own room tool behaves as before (a person sees and names rooms directly). | `{ name, polygon, levelId?, color?, wallHeight?, wallThickness?, outdoor?, rename?, doors?, windows? }` | `{ zoneId, slabId, ceilingId, wallIds, reusedWalls, areaSqMeters, doorIds, windowIds, skippedOpenings? }`; refusals `cannot_enclose`, `outdoor_room_overlap`, `room_named_by_person` | | `add_door` | Add a door to a straight wall at `t` (0..1 along it) with the editor's placement rules, or a passage with no leaf (`openingKind: "opening"`); it slides to stay on the wall and reports `clamped`. `doorType`, `style` and the outline (`openingShape`) match a reference, `code` builds what they cannot, and `nodeId` rebuilds a scripted door. | `{ wallId, t?, width?, height?, openingKind?, hingesSide?, swingDirection?, doorType?, style?, openingShape?, code?, nodeId?, force? }` | `{ doorId, wallId, localX, t, clamped, achieved }`; refusals `wall_not_found`, `not_a_wall`, `curved_wall`, `wall_too_short`, `opening_overlap` (unless `force`) | @@ -369,7 +415,7 @@ an optional `height` and uses the same default. | `furnish_room` | Furnish a room for its type from the host's item catalog: the main piece against the wall facing the door, the rest along a side wall. Nothing lands in a door's clear zone or on another item: a piece that does not fit is nudged, else skipped, and `skipped` says why. | `{ zoneId?, polygon?, levelId?, roomType, doorWallIndex? }` | `{ placed, itemIds, skipped, doorWallIndex, doorsDetected }`; refusals `room_required`, `roof_level`, `level_not_found`, `no_catalog` | | `add_corner_window` | A window wrapping the corner where two walls meet: one window per wall, both running to the corner and joined, the glass fused (a fixed window) or at a thin post (the other window types, whose frames stay), at any angle from 10° to 170° (a square corner, a bay's 135°, an acute one). `corner` is the point the walls of `levelId` end at (default the floor in view: stacked storeys share their corners' points), `[x, z]` in the level's plan as `get_walls` gives their ends; `width` is how far the glass runs along each wall from the corner (default 1.2 m) and `widths` overrides it per wall id; `post` is `'none'` (the glass fused, a fixed window's default) or `'post'` (the other types' default and only choice); `height` defaults to 1.5 m and the window is centred on the wall unless `sillHeight` (its bottom edge above the floor) is given; `wallIds` (two) is needed only when more than two walls end at the corner; `windowType` and `style` are `add_window`'s (a style needs a Fixed window). By hand: select a window that ends at a corner and use the window panel's Corner section (Wrap the corner, This side and Other side, Fused glass or Corner post for a fixed window, Unwrap). | `{ corner: [x, z], levelId? (or level), wallIds?, width?, widths?, height?, sillHeight?, post?, windowType?, style? }` | `{ windowIds, wallIds, angle, post, achieved }`; refusals `no_corner` (no two walls end there), `corner_ambiguous` (more than two do: pass `wallIds`), `corner_angle` (under 10° or over 170°: use `add_window`), `corner_not_fused` (`post: 'none'` on a type other than fixed), `width_exceeds_wall`, `style_needs_fixed_window` | | `merge_windows` | Make two windows that exist one window, as the editor's Merge windows button does; nothing is recreated. On two walls that meet at a corner, each window within 0.30 m of it, they become one corner window (the `add_corner_window` result): each is slid to the corner keeping its width, the glass fused (a fixed window's; other types meet at a post), and both take the first window's height, sill and type, so name first the window whose look you want; the corner is refused over 170° or under 10°. On one wall, two windows with at most 0.30 m between them and nothing between become one window spanning both (overlapping or one inside the other, their union), from the lower sill to the higher head, with the first window's type and style. Use it for two windows already built that open a corner or one wide opening (`verify_scene`'s `corner_unjoined` names a pair for the first); `add_corner_window` builds a new pair. By hand: select two windows and use Merge windows in the multi-selection panel, or place or drag a window near a corner where one waits and click or release (Alt places it plain). | `{ windowIds: [a, b] }` | `{ kind: 'corner' \| 'same_wall', windowIds, removedIds, achieved }`; refusals `window_not_found`, `not_a_window`, `same_window`, `window_not_on_wall`, `window_already_joined` (unwrap it first), `windows_already_joined`, `windows_not_adjacent`, `opening_between`, `walls_not_meeting`, `corner_angle`, `window_not_at_corner`, `height_exceeds_wall`, `opening_overlap` | -| `apply_patch` | Batched create/update/delete/move, validated and dry-run before commit. Batch-first is the default: send all create/update/delete ops for a build step in one atomic call (stable order, later ops may reference earlier created ids); do not loop one-op calls. A create with an id already in the scene is refused (`node_exists`); delete it earlier in the same patch to replace it. Updates cannot change `id` or `type` (`identity_change`), `object` or `children` (`immutable_field`), move a node under a missing or childless parent (`invalid_parent`), or add schema issues (`invalid_update`); default gutters and downspouts a delete regenerates are addressable only in a later call (`regenerated_default`). Refusals are tool errors with JSON text `{ code, patchIndex, id, message }`. A level created without a `height`, or an update that clears it, gets the height the editor's load derives from what the level holds (an empty level: 2.5 m), one line per level in `notes`. | `{ patches: Patch[] }` | `{ applied: number, notes?: string[] }` | +| `apply_patch` | Batched create/update/delete/move, validated and dry-run before commit. Batch-first is the default: send all create/update/delete ops for a build step in one atomic call (stable order, later ops may reference earlier created ids); do not loop one-op calls. A create with an id already in the scene is refused (`node_exists`); delete it earlier in the same patch to replace it. Updates cannot change `id` or `type` (`identity_change`), `object` or `children` (`immutable_field`), move a node under a missing or childless parent (`invalid_parent`), or add schema issues (`invalid_update`); default gutters and downspouts a delete regenerates are addressable only in a later call (`regenerated_default`). Refusals are tool errors with JSON text `{ code, patchIndex, id, message }`. A level created without a `height`, or an update that clears it, gets the height the editor's load derives from what the level holds (an empty level: 2.5 m), one line per level in `notes`. A roof the patch makes or moves onto the storey whose walls carry it is seated on the level above those walls, one line per roof in `notes`; a new storey at or above the roof level moves it up. Refused: storey content on a roof level (`roof_level`), declaring a level that holds storey content the roof level (`roof_level_holds_storey`), floor indexes that put the roof level under a storey (`roof_level_on_top`). | `{ patches: Patch[] }` | `{ applied: number, notes?: string[] }` | | `add_level` | Add an empty level to a building, as the editor does: on top of its storeys, under its roof level (which moves up one), or below the lowest for a basement. Omit `buildingId` for the scene's only building. | `{ buildingId?, position?, name?, height? }` | `{ levelId, buildingId, floorIndex, height, shiftedLevelIds? }`; refusals `building_required`, `building_not_found`, `no_building` | | `add_wall` | Add a wall to a level, straight or, with `curveOffset`, an arc. Omit `levelId` for the lowest storey. | `{ start, end, levelId?, thickness?, height?, curveOffset? }` | `{ wallId, levelId, length }`; refusals `roof_level`, `wall_too_short`, `level_not_found` | | `add_fence` | Add a fence along points on a level, one fence per side; `closed` runs it back to the first point (a lot's boundary). Privacy panels unless `style` says otherwise. | `{ points, closed?, height?, style?, baseStyle?, materialPreset?, levelId? }` | `{ count, fenceIds, levelId, length, achieved }`; refusals `fence_too_short`, `roof_level`, `unknown_material`, `level_not_found` | @@ -399,13 +445,47 @@ returns an error with the code `tool_retired`, the tool that replaced it `verify_scene`, `find_nodes` into `find_by_type` and `describe_node` into `get_node` (`RETIRED_MCP_TOOLS` in `@pascal-app/core/agent-tools`). +```jsonc +// find_nodes { zoneId } → find_by_type, with or without a type +{ "name": "find_by_type", "arguments": { "type": "window", "zoneId": "zone_kitchen" } } +{ "name": "find_by_type", "arguments": { "sourceIdPrefix": "ground/exterior-01" } } +// describe_node → get_node: resolvedHeight, resolvedThickness, ancestorIds, displayName… +{ "name": "get_node", "arguments": { "id": "wall_host" } } +// validate_scene → verify_scene: schema_invalid issues by nodeId and path, room findings +{ "name": "verify_scene", "arguments": {} } +``` + +### Examples + +```jsonc +// The lot before the house: frontage, depth, each buildable edge as a front, the ground +{ "name": "get_site", "arguments": {} } +// An L stair with a glass railing, and porch steps to no level +{ "name": "create_stair", "arguments": { "x": 2, "z": 1, "layout": "l", "turn": "left", "lowerSteps": 6, "railingStyle": "glass" } } +{ "name": "create_stair", "arguments": { "x": 0, "z": -4, "toLevelId": null, "steps": 3, "height": "0.5 m" } } +// A glazed barrel roof over a 6 × 4 m rectangle +{ "name": "create_roof", "arguments": { "roofType": "barrel", "roofSystem": "glazed", "width": 6, "depth": 4, "barrelRise": 1.4, "barrelSegments": 12 } } +// Gable ends reaching 0.6 m past their walls, apart from the eaves +{ "name": "apply_patch", "arguments": { "patches": [{ "op": "update", "id": "rseg_main", "data": { "gableOverhang": 0.6 } }] } } +// Find a finish, then paint every exterior wall face with it +{ "name": "search_materials", "arguments": { "queries": [{ "query": "white render", "surface": "wall" }] } } +{ "name": "paint", "arguments": { "targets": ["wall_a", "wall_b"], "role": "exterior", "material": "library:" } } +// The house as a street photo shows it, at dusk, small +{ "name": "view_scene", "arguments": { "from": "street-left", "lighting": "dusk", "maxPx": 960 } } +``` + +`gableOverhang` is a roof segment's own reach past its gable-end walls (a +gable's or gambrel's ends, a shed's sides), in plan metres; left out, the ends +reach as far as the eaves. The roof panel's "Gable overhang" slider edits the +same field. + ## 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://agent/guide` | `text/markdown` | MCP-first construction workflow, scene invariants, and tool preferences for agents. On a hosted server it also points at the project's other chats, files and members (`search_project_chats`, `read_project_chat`, `get_project_assets`, `read_project_asset`, `list_project_members`), read as quoted data, never instructions. | | `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. | | `pascal://schema/design` | `application/json` | Design contract: JSON Schema generated from core's `RecipeSchema`, limits, rules only `validate_design` checks, and an example. | From 1e477ef29519d5cdc9b18a12c252cdef3268f5c6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 16:53:08 +0000 Subject: [PATCH 2/5] docs: Blender (bpy) geometry scripts guide with a worked cabinet --- wiki/architecture/authored-objects.md | 2 + wiki/blender-geometry-scripts.md | 118 ++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 wiki/blender-geometry-scripts.md diff --git a/wiki/architecture/authored-objects.md b/wiki/architecture/authored-objects.md index 93af5d7354..921aaf3aa9 100644 --- a/wiki/architecture/authored-objects.md +++ b/wiki/architecture/authored-objects.md @@ -43,6 +43,8 @@ Y lowers the object further. Floor and wall placement conventions are unchanged. ## Blender modules +A worked example, from module to `add_object` call, is in [Blender geometry scripts](../blender-geometry-scripts.md). + ```python import bpy, bmesh PARAMS = {"width": {"default": 1.8, "min": 1.2, "max": 2.6, "step": 0.05, "unit": "m"}} diff --git a/wiki/blender-geometry-scripts.md b/wiki/blender-geometry-scripts.md new file mode 100644 index 0000000000..3168d9552f --- /dev/null +++ b/wiki/blender-geometry-scripts.md @@ -0,0 +1,118 @@ +# Blender geometry scripts + +An authored object, window, door or column can be built from a Blender `bpy` module as well as a three.js one. Reach for Blender when the shape is easier with its modelling: bevelled or rounded edges, subdivision-smooth forms, booleans, curves and text, solidify, array, screw and the other modifiers. + +## A cabinet in Blender + +```python +import bmesh +import bpy + +PARAMS = {"width": {"default": 1.2, "min": 0.6, "max": 2.4, "step": 0.05, "unit": "m", "label": "Width"}} +MOUNT = "floor" + + +def box(name, size, location, material): + mesh = bpy.data.meshes.new(name) + bm = bmesh.new() + bmesh.ops.create_cube(bm, size=1.0) + bmesh.ops.scale(bm, vec=size, verts=bm.verts) + bm.to_mesh(mesh) + bm.free() + mesh.materials.append(material) + obj = bpy.data.objects.new(name, mesh) + obj.location = location + bpy.context.scene.collection.objects.link(obj) + return obj + + +def build(params): + w = params["width"] + wood = bpy.data.materials.new("slot_wood") + wood.diffuse_color = (0.55, 0.35, 0.2, 1) + metal = bpy.data.materials.new("slot_metal") + metal.diffuse_color = (0.7, 0.7, 0.72, 1) + + body = box("part:body", (w, 0.5, 0.9), (0, 0, 0.45), wood) + body["type"] = "cabinet" + bevel = body.modifiers.new("bevel", "BEVEL") + bevel.width = 0.01 + bevel.segments = 2 + + action = bpy.data.actions.new("open") + for name, x, sign in (("part:door_left", -w / 4, 1), ("part:door_right", w / 4, -1)): + door = box(name, (w / 2 - 0.01, 0.02, 0.86), (x, -0.26, 0.45), metal) + door.animation_data_create() + door.animation_data.action = action + door.keyframe_insert("rotation_euler", index=2, frame=1) + door.rotation_euler.z = sign * 1.4 + door.keyframe_insert("rotation_euler", index=2, frame=24) + door.rotation_euler.z = 0 + + top = bpy.data.objects.new("anchor:top", None) + top.location = (0, 0, 0.9) + bpy.context.scene.collection.objects.link(top) + + lamp = bpy.data.objects.new("light:lamp", bpy.data.lights.new("lamp", "POINT")) + lamp.location = (0, 0, 0.85) + lamp["intensity"] = 3 + bpy.context.scene.collection.objects.link(lamp) +``` + +Send it to `add_object` with `language: "bpy"`: + +```json +{ + "name": "add_object", + "arguments": { + "language": "bpy", + "code": "", + "params": { "width": 1.5 }, + "position": [2, 0, 1], + "name": "Oak cabinet", + "category": "cabinet", + "reason": "Bevelled two-door cabinet for the hallway" + } +} +``` + +The result lists its parts (`body`, `door_left`, `door_right`), paint slots (`wood`, `metal`), the `top` socket, the `lamp` light and the `open` clip, which becomes the cabinet's open/close control. The fixture is `packages/geometry-script/src/__fixtures__/blender/cabinet.py`. + +## The module + +- `PARAMS` declares the parameters, as three.js's `params` does; `build(params)` gets the resolved values. +- `MOUNT` is `floor` (the default), `wall-side`, `wall` or `ceiling`. +- `build` adds objects to the empty scene it is given. Everything in the scene is exported as a GLB, modifiers applied. +- Use the data API and `bmesh`, not operators that need a 3D viewport. + +## Conventions in Blender terms + +| In the module | Becomes | +| --- | --- | +| Metres, Z up, the front facing −Y (Blender's Front view) | Pascal's metres, Y up, front +Z | +| object `part:`, custom property `type` | an addressable, typed part (`find_by_type`) | +| material `slot_` | a paint slot; while its shader keeps the default base colour, its viewport `diffuse_color` colours it | +| object `light:` holding a light, custom property `intensity` (default 2) | a switchable light; Blender's watts are not read | +| empty `anchor:` | a socket | +| object `cutout`, `cut:wall`, `cut:ceiling`, `cut:slab` | a cutter for the mounted host | +| object `collider` | the walkthrough proxy | +| one action named `open`, `close`, `loop` or anything else, assigned to every object it moves | one clip | + +## Edit and read back + +`get_source` returns the Python and its `language`. Pass `nodeId` with new `code`, new `params`, or both; an edit keeps the object's language unless `language` says otherwise, and a params-only edit rebuilds the stored module. In the editor the Parameters panel rebuilds a bpy object too ("Rebuilding in Blender…"). A failing module comes back with Blender's error and line. + +## Where it runs + +Blender cannot run in a browser, so a bpy module is built by its host: + +- **Editor**: the host configures a builder with `configureBlenderScriptBuilder` from `@pascal-app/editor`; without one, bpy is refused. +- **Local MCP**: set `PASCAL_BLENDER` to a Blender binary (it runs `blender -b`). This runs the module on your machine with no sandbox, like `PASCAL_SERVER_SCRIPT_COMPILE=1` for three.js. + +```bash +PASCAL_BLENDER=/Applications/Blender.app/Contents/MacOS/Blender bunx @pascal-app/mcp +``` + +- **Your own host**: `runBlenderLocally` (`@pascal-app/geometry-script/blender-local`) runs a local binary; `compileBlenderBuild` (`@pascal-app/geometry-script/blender`) turns the GLB a run exported into the same manifest a three.js build gets. It runs no model code, so any host may call it. + +The compile pipeline and naming rules are in [authored objects](architecture/authored-objects.md#blender-modules). From 5744060727306cd3e013f2112157565f2409e52f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 16:53:38 +0000 Subject: [PATCH 3/5] docs(editor): host guide for loading slot, action menu placement, theme, views and selection exports --- README.md | 4 +- packages/editor/README.md | 106 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 packages/editor/README.md diff --git a/README.md b/README.md index 02597fa3e4..878c87608e 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,9 @@ import { builtinPlugin } from '@pascal-app/nodes' await loadPlugin(builtinPlugin) ``` -See the [`@pascal-app/viewer` quick start](packages/viewer/README.md#usage) for a React example. +See the [`@pascal-app/viewer` quick start](packages/viewer/README.md#usage) for a React example, +and [`@pascal-app/editor`](packages/editor/README.md) to embed the full editor: its loading +surface, tool-menu placement, light or dark chrome, views and selection helpers. ## Repository Architecture diff --git a/packages/editor/README.md b/packages/editor/README.md new file mode 100644 index 0000000000..bfbc95ac07 --- /dev/null +++ b/packages/editor/README.md @@ -0,0 +1,106 @@ +# @pascal-app/editor + +The Pascal building editor as a React component: tools, panels, the floor plan, views and the agent chat surfaces, composed over `@pascal-app/viewer`. + +## Installation + +```bash +npm install @pascal-app/core @pascal-app/viewer @pascal-app/editor @pascal-app/nodes +``` + +Load the built-in plugin once before mounting, as for the viewer (see the [viewer quick start](../viewer/README.md#usage)). + +## Usage + +```tsx +import { Editor } from '@pascal-app/editor' + +export function ProjectPage({ projectId }: { projectId: string }) { + return ( +
+ loadScene(projectId)} + onSave={(scene) => saveScene(projectId, scene)} + actionMenuPlacement="top" + loadingSlot={} + onLoaderChange={(visible) => track('editor-loading', visible)} + /> +
+ ) +} +``` + +`apps/editor/components/scene-loader.tsx` is a complete host: navbar, sidebar tabs, toolbars and persistence. + +## Host props + +| Prop | What it does | +| --- | --- | +| `loadingSlot` | Your loading surface, shown in the canvas panes of both layouts and in 2D and 3D while the scene loads. The editor still decides when loading is over and fires `onLoaderChange`; a failed load always shows the editor's error and retry screen. Omit it or pass `null` for the default loader. | +| `actionMenuPlacement` | `'bottom'` (default) or `'top'`. With `'top'`, the v2 layout gives the tool menu its own row under the toolbar, centred on the scene panes and following them in split, so the bottom strip is free; v1 floats it at the top edge. Mobile keeps the bottom rail. | +| `viewBar` | Each pane's view bar (v2, default `true`). Turn it off for a host that shows one fixed view. | +| `sceneOverlay` | Chrome over the 3D and 2D panes with the level selector and tool dock, hidden while no scene view is on screen. | +| `stageOverlay` | A full-bleed surface over the whole stage; the scene stays mounted underneath. To show something beside the scene, register a view instead. | + +## Theme + +The editor sets no theme of its own: its chrome follows the nearest `.dark` ancestor, so leave the class off for a light editor. Menus and tooltips portal to ``, so put `dark` there too (or on ``) for a dark one. Hand the person a theme switch with `InterfaceThemeRow` in the settings panel's `preferences` slot: + +```tsx +import { InterfaceThemeRow, SettingsPanel, type InterfaceTheme } from '@pascal-app/editor' + + setTheme(next)} />} +/> +``` + +`theme` is `'system' | 'light' | 'dark'`; applying it (toggling `.dark`) is the host's. A host that passes no row gets none. The Preview stage, the walkthrough HUD and the controls bar stay dark in either theme. The viewer's own "Scene theme" is separate (see [materials and themes](../../wiki/architecture/materials-and-themes.md)). + +## Views + +The stage is one or two panes, each showing a view: 3D, 2D, or one a plugin registers. + +```tsx +import { openEditorView, PLUGINS_VIEW_ID, registerEditorHostView } from '@pascal-app/editor' + +registerEditorHostView({ + id: 'acme:signage:schedule', + label: 'Sign schedule', + icon: { kind: 'url', src: '/icons/signs.webp' }, + component: () => import('./schedule-view'), + pluginId: 'acme:signage', +}) + +openEditorView('acme:signage:schedule', { beside: true }) // split, next to the scene +openEditorView(PLUGINS_VIEW_ID) // the plugin directory +``` + +Each workspace keeps its own layout, and swapping panes never remounts the 3D canvas or the plan. `\` toggles split. The full contract is in [plugin authoring](../../wiki/architecture/plugin-authoring.md). + +## Selecting and showing a node + +The helpers the editor's own lists use: + +```ts +import { enterUnitFocus, frameViewerNode, selectZoneOrRoom } from '@pascal-app/editor' + +selectZoneOrRoom(zoneId) // 'room' for an enclosed room, else 'zone' +frameViewerNode(nodeId, source) // the node's saved view, else its box; the plan frames it when no 3D view is on screen +enterUnitFocus(unitId) // focus a unit, as the site panel does +``` + +What a host's chat points at (`useViewer.pointedIds`) lights in the plan and as a room outline, as well as in 3D. See [selection managers](../../wiki/architecture/selection-managers.md) and [point and ask](../../wiki/architecture/point-and-ask.md). + +## Plugin chat surfaces + +A plugin's `PluginChatExtension` can give a host's chat a start screen (`start`), shown while the thread is empty and acting through `ChatStartProps` (`send`, `attach`, `requirePro`). `IconRefGlyph` and `IconRefImage` render registry icons the way the editor does: + +```tsx +import { IconRefGlyph } from '@pascal-app/editor' + + +``` + +See [plugin authoring, agent tools and chat extensions](../../wiki/architecture/plugin-authoring.md#agent-tools-and-chat-extensions). From 5aedb0c8569c7db798ca9710ee58e26c5a31f12d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 16:53:38 +0000 Subject: [PATCH 4/5] =?UTF-8?q?docs:=20release=20process=20=E2=80=94=20eig?= =?UTF-8?q?ht=20packages,=20npm=20preflight,=20deploy-key=20push?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTRIBUTING.md | 14 ++++++++++++++ README.md | 15 +++++++++------ 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6b364bf6cb..2075ed4d61 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -86,6 +86,20 @@ New node kinds and sidebar panels can ship as a plugin instead of editing the bu - Include screenshots or recordings for visual changes - If you're unsure about an approach, open an issue or discussion first +## Releases + +Maintainers release every `@pascal-app/*` package together from the **Release** workflow; contributors never bump versions in a PR. Two things a PR can change need a maintainer before the next release: + +- **A new package.** Add it to `PACKAGES` in `.github/workflows/release.yml`, then a maintainer publishes it to npm once by hand and adds its trusted publisher. Until then the workflow fails at its first step, before anything is published: + + ```bash + cd packages/ && bun run build && npm publish --access public --tag latest --ignore-scripts + ``` + +- **The release push.** The release commit and tags go to `main` with the `RELEASE_DEPLOY_KEY` deploy key, the one identity besides admins that the `main` ruleset lets through. Keep it in the ruleset's bypass list when the ruleset changes. + +[Releasing to npm](wiki/npm-release.md) has the trusted-publisher settings, key rotation and recovery steps. + ## Reporting bugs Use the [Bug Report](https://github.com/pascalorg/editor/issues/new?template=bug_report.yml) template. Include steps to reproduce — this helps us fix things faster. diff --git a/README.md b/README.md index 878c87608e..35d8b3a7ef 100644 --- a/README.md +++ b/README.md @@ -546,12 +546,15 @@ turbo build --filter=@pascal-app/core Releases run from `.github/workflows/release.yml` (`workflow_dispatch`, with `bump` and `dry-run` inputs). Every package shares one version: the workflow -bumps all seven, rewrites the internal `@pascal-app/*` ranges, builds, publishes -in dependency order (`core` → `viewer` → `editor` → `nodes` → `mcp` → -`ifc-converter` → `cli`), then commits the release and pushes one tag per -package. A dry run validates the builds without touching the registry. -[Releasing to npm](wiki/npm-release.md) covers authentication, recovering a -partial run, publishing by hand and the follow-ups. +bumps all eight, rewrites the internal `@pascal-app/*` ranges, checks that each +one already exists on npm (a package never published fails the run before +anything is built), builds, publishes in dependency order (`core` → +`geometry-script` → `viewer` → `editor` → `nodes` → `mcp` → `ifc-converter` → +`cli`), then commits the release and pushes one tag per package with the release +deploy key, which the `main` ruleset lets through. A dry run validates the builds +without touching the registry. [Releasing to npm](wiki/npm-release.md) covers +authentication, adding a package, the deploy key, recovering a partial run, +publishing by hand and the follow-ups. --- From b1c8321d71e5260983b65d9599011f30d630a380 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 16:53:38 +0000 Subject: [PATCH 5/5] =?UTF-8?q?docs:=20changelog=20entries=20for=20tool=20?= =?UTF-8?q?and=20host=20API=20changes,=208=E2=80=9315=20Oct?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ebf87d882..8cd6c211c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,15 +4,36 @@ ### Features -- **`view_scene` sees the house as a front photo does.** `from` also takes the sides a photo is named by, standing in the street: `street`, `street-left`, `street-right`, `left`, `right`, `back`, `back-left`, `back-right`, from the street edge the site records, else the house's own front (its −z, the way a generated house faces; core's `buildingStreet`, which the hosted Architect's street-relative tools read too). `camera: 'current'` looks through the person's own view where the host has one (the MCP refuses it, `no_current_camera`), `lighting` renders the picture in a photo's light (`day`, `overcast`, `sunset`, `dusk`, `night`) and `maxPx` makes it smaller (a picture is never longer than 1920 px). A view of a building, or of the whole scene, frames its roofs with its storeys, and a plugin's node on a level (a roof plane) up to its storey's top; a street side with buildings on several sites and no target or interior floor is refused (`which_building`). A `SceneViewHost` receives the `lighting` to render in. `camera-controls:capture-session` is a typed event, counted once in core (`isCaptureSessionOn`, `subscribeCaptureSessions`) from the moment it loads: runs that overlap (a sheet's pictures, an agent's view) present the finished house once and put the person's view back after the last, and a plugin draws its own finished view from the same count. -- **Self-hosting is one container.** `ghcr.io/pascalorg/pascal-editor:` ships the same portable runtime as the CLI, multi-arch, published by the release workflow. The public URL is read per request (`PASCAL_PUBLIC_URL`, replacing the build-time `NEXT_PUBLIC_APP_URL` and `MINT_PASCAL_HOST_ORIGIN`), so one image serves any domain or port. A single admin password (`PASCAL_ADMIN_PASSWORD`) gates every page and API behind `/login`; agents keep the `PASCAL_SCENE_API_TOKEN` bearer; a network host with nothing configured serves a setup page and nothing else; `PASCAL_AUTH=proxy` defers to an authenticating reverse proxy. See `SETUP.md`, "Self-hosting". +- **`view_scene` sees the house as a front photo does.** `from` also takes the sides a photo is named by, standing in the street: `street`, `street-left`, `street-right`, `left`, `right`, `back`, `back-left`, `back-right`, from the street edge the site records, else the house's own front (its −z, the way a generated house faces; core's `buildingStreet`, which the hosted Architect's street-relative tools read too). `camera: 'current'` looks through the person's own view where the host has one (the MCP refuses it, `no_current_camera`), `lighting` renders the picture in a photo's light (`day`, `overcast`, `sunset`, `dusk`, `night`) and `maxPx` makes it smaller (a picture is never longer than 1920 px). A view of a building, or of the whole scene, frames its roofs with its storeys, and a plugin's node on a level (a roof plane) up to its storey's top; a street side with buildings on several sites and no target or interior floor is refused (`which_building`). A `SceneViewHost` receives the `lighting` to render in. `camera-controls:capture-session` is a typed event, counted once in core (`isCaptureSessionOn`, `subscribeCaptureSessions`) from the moment it loads: runs that overlap (a sheet's pictures, an agent's view) present the finished house once and put the person's view back after the last, and a plugin draws its own finished view from the same count. Every picture is planned at 1920 × 1200, and `interior: { levelId }` looks inside one floor ([#1034](https://github.com/pascalorg/editor/pull/1034), [#1051](https://github.com/pascalorg/editor/pull/1051), [#1075](https://github.com/pascalorg/editor/pull/1075)) +- **One finder, one node read, one scene check.** `find_by_type` takes `find_nodes`' filters (`parentId`, `zoneId`, `sourceId`, `sourceIdPrefix`, or none, with `limit`), `get_node` gives what `describe_node` added (`ancestorIds`, resolved heights and thicknesses, `displayName`, `missingChildIds`), and `verify_scene` lists every schema error by node and path and the spaces the walls leave without a room ([#1081](https://github.com/pascalorg/editor/pull/1081), [#1084](https://github.com/pascalorg/editor/pull/1084)) +- **The site comes first.** `get_site` reads the lot, its buildable area, street, north, ground and zoning, each buildable edge with its frontage and depth, and the 30–45% footprint a house usually covers; `place_building` stands a house on its lot by moving the building node only ([#1044](https://github.com/pascalorg/editor/pull/1044), [#1080](https://github.com/pascalorg/editor/pull/1080)) +- **One notation.** Every length and angle of the shared tools reads a bare number in its field's unit or text in any unit (inches, feet, millimetres included); `roofPitch()` reads a pitch as degrees or rise over run (`"6:12"`, `"6/12"`, `"6 in 12"`), and `create_roof`'s `overhang` is the eaves' reach in plan ([#1079](https://github.com/pascalorg/editor/pull/1079)) +- **One finish tool.** `paint` gives every part with surfaces a finish (blocks, stairs, cabinets, roof fascias, wall faces by their rooms) and keeps native materials as scene materials; `search_materials` searches and browses the library a page at a time ([#1034](https://github.com/pascalorg/editor/pull/1034), [#1076](https://github.com/pascalorg/editor/pull/1076)) +- **L and U stairs.** `create_stair` takes `layout: 'l' | 'u'`, `turn`, `lowerSteps`, railing styles and materials, and `toLevelId: null` for porch or deck steps ([#1073](https://github.com/pascalorg/editor/pull/1073)) +- **Roofs on the roof level.** A roof lives on the level directly above the walls that carry it: `create_roof`, the Roof tool, `apply_patch`, paste, duplicate and every other writer seat it there; `verify_scene` reports older roofs (`roof_mixed_in_storey`) and `move_roofs_to_roof_level` or a roof panel's "Move to the roof level" moves them without changing how they look ([#1039](https://github.com/pascalorg/editor/pull/1039), [#1047](https://github.com/pascalorg/editor/pull/1047), [#1057](https://github.com/pascalorg/editor/pull/1057)) +- **Gable overhang.** A roof segment's `gableOverhang` sets how far its gable ends reach past their walls, apart from the eaves, with a "Gable overhang" slider; left out, the ends reach as the eaves do ([#1082](https://github.com/pascalorg/editor/pull/1082)) +- **Barrel and glazed roofs.** `roofType: 'barrel'` arches a segment; `roofSystem: 'glazed'` builds a gable, straight shed or barrel roof as panes and framing, with grids, panel overrides and `glazing-frame` / `glazing-glass` / `glazing-solid` paint slots. See `wiki/glazed-roofs.md` ([#1055](https://github.com/pascalorg/editor/pull/1055)) +- **Blender geometry scripts.** An authored object, window, door or column can be a `bpy` module (`language: 'bpy'`), built by a host-configured `BlenderScriptBuilder` or, on a local MCP, the binary `PASCAL_BLENDER` names. See `wiki/blender-geometry-scripts.md` ([#1033](https://github.com/pascalorg/editor/pull/1033)) +- **Plugin agent tools.** A plugin ships `./agent-tools` contracts and `./agent-operations` beside its manifest; an MCP host registers them with `registerOperationTool`, and a chat extension can add cards, `/` actions and a start screen ([#1026](https://github.com/pascalorg/editor/pull/1026), [#1043](https://github.com/pascalorg/editor/pull/1043)) +- **Views.** The stage is one or two panes, each a view: 3D, 2D or a plugin's (`registerEditorHostView`, `openEditorView`), with a view bar per pane and a plugin directory view (`PLUGINS_VIEW_ID`) ([#1023](https://github.com/pascalorg/editor/pull/1023)) +- **More for hosts.** `loadingSlot` supplies the loading surface, `actionMenuPlacement: 'top'` docks the tool menu under the toolbar, and `selectZoneOrRoom`, `frameViewerNode`, `enterUnitFocus`, `IconRefGlyph` and `IconRefImage` are exported; the plan frames a node when it is the only view and lights what a host's chat points at. See `packages/editor/README.md` ([#995](https://github.com/pascalorg/editor/pull/995), [#1040](https://github.com/pascalorg/editor/pull/1040), [#1043](https://github.com/pascalorg/editor/pull/1043), [#1070](https://github.com/pascalorg/editor/pull/1070)) +- **Light chrome.** The editor follows the host's `.dark` class instead of forcing dark, and `InterfaceThemeRow` gives a host's settings a System / Light / Dark row ([#1041](https://github.com/pascalorg/editor/pull/1041)) +- **Self-hosting is one container.** `ghcr.io/pascalorg/pascal-editor:` ships the same portable runtime as the CLI, multi-arch, published by the release workflow. The public URL is read per request (`PASCAL_PUBLIC_URL`, replacing the build-time `NEXT_PUBLIC_APP_URL` and `MINT_PASCAL_HOST_ORIGIN`), so one image serves any domain or port. A single admin password (`PASCAL_ADMIN_PASSWORD`) gates every page and API behind `/login`; agents keep the `PASCAL_SCENE_API_TOKEN` bearer; a network host with nothing configured serves a setup page and nothing else; `PASCAL_AUTH=proxy` defers to an authenticating reverse proxy. See `SETUP.md`, "Self-hosting" ([#1032](https://github.com/pascalorg/editor/pull/1032), [#1038](https://github.com/pascalorg/editor/pull/1038)) - **The standalone app is a product, not a shell.** Home at `/` lists projects (the unsaved scratch canvas and `/scenes` are gone; `/scenes` and `/scene/` redirect to `/` and `/project/`), new ones are named `Project #n`, each card renames and deletes; the editor gets a navbar (logo back to home, inline rename, Preview); `/settings` holds the instance info, the agent endpoint and sign-out; thumbnails are captured after edits and stored under the data directory. `/privacy`, `/terms` and `vercel.json` leave the open-source app. -- **Public embeds.** A Share button in the editor navbar turns on a per-project public link: a read-only 3D view at `/embed/` that needs no password and can be iframed, with a copyable link and `