From 4cb523558716bfd0d269cb9e9f6756e989c5c9d3 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Mon, 12 Oct 2026 15:43:19 -0400 Subject: [PATCH 01/16] feat(core): add_fence on every agent surface The hosted chat had its own add_fence (one segment, the store written directly) and the MCP had none. add_fence is now one contract and one core operation: a fence along points on a level, one fence per side, closed back to the first point for a lot's boundary, privacy panels unless a style is asked (a fence made with no style came out as open rails). It refuses a side shorter than 10 cm, a roof level and a finish the library lacks, and it runs in run_batch like add_wall. The fence style enums move to a dependency-free schema module so the contract stays zod-only. From Adam's drafts of pascalorg/editor#1024, which landed with the hosted chat's own add_fence kept. Co-authored-by: Adam NAILI Co-Authored-By: Claude Opus 5.5 --- .../agent-operations/__fixtures__/cases.ts | 8 +- .../__fixtures__/structure-cases.ts | 89 +++++++++++++++++++ .../core/src/agent-operations/add-fence.ts | 69 ++++++++++++++ packages/core/src/agent-operations/index.ts | 3 + packages/core/src/agent-tools/batch.ts | 1 + packages/core/src/agent-tools/fences.ts | 39 ++++++++ packages/core/src/agent-tools/index.ts | 3 + packages/core/src/schema/nodes/fence-types.ts | 24 +++++ packages/core/src/schema/nodes/fence.ts | 21 +---- packages/mcp/README.md | 1 + .../src/tools/read-tool-annotations.test.ts | 1 + packages/mcp/src/tools/shared-tools.ts | 6 ++ .../tool-annotation-justifications.json | 13 +++ scripts/openai-tool-annotation-policy.ts | 1 + 14 files changed, 259 insertions(+), 20 deletions(-) create mode 100644 packages/core/src/agent-operations/add-fence.ts create mode 100644 packages/core/src/agent-tools/fences.ts create mode 100644 packages/core/src/schema/nodes/fence-types.ts diff --git a/packages/core/src/agent-operations/__fixtures__/cases.ts b/packages/core/src/agent-operations/__fixtures__/cases.ts index 1b730dd217..768f90d5a0 100644 --- a/packages/core/src/agent-operations/__fixtures__/cases.ts +++ b/packages/core/src/agent-operations/__fixtures__/cases.ts @@ -24,7 +24,12 @@ import { ROOM_CASES } from './room-cases' import { SEARCH_ASSETS_CASES } from './search-assets-cases' import { CORNER_WINDOW_CASES } from './corner-window-cases' import { MERGE_WINDOWS_CASES } from './merge-windows-cases' -import { ADD_LEVEL_CASES, ADD_WALL_CASES, CREATE_STAIR_CASES } from './structure-cases' +import { + ADD_FENCE_CASES, + ADD_LEVEL_CASES, + ADD_WALL_CASES, + CREATE_STAIR_CASES, +} from './structure-cases' import { VERIFY_SCENE_CASES } from './verify-scene-cases' /** @@ -894,6 +899,7 @@ export const AGENT_TOOL_CASES: readonly AgentToolCase[] = [ ...FURNISH_ROOM_CASES, ...SEARCH_ASSETS_CASES, ...ADD_WALL_CASES, + ...ADD_FENCE_CASES, ...ADD_LEVEL_CASES, ...CREATE_STAIR_CASES, ...PLACE_ITEMS_CASES, diff --git a/packages/core/src/agent-operations/__fixtures__/structure-cases.ts b/packages/core/src/agent-operations/__fixtures__/structure-cases.ts index 70cfcaab1c..e0167a5365 100644 --- a/packages/core/src/agent-operations/__fixtures__/structure-cases.ts +++ b/packages/core/src/agent-operations/__fixtures__/structure-cases.ts @@ -2,6 +2,7 @@ import { type AnyNode, BuildingNode, CeilingNode, + type FenceNode, LevelNode, SiteNode, SlabNode, @@ -170,6 +171,94 @@ export const ADD_WALL_CASES: AgentToolCase[] = [ }, ] +const fencesOn = (nodes: Readonly>, levelId: string) => + Object.values(nodes).filter( + (node): node is FenceNode => node.type === 'fence' && node.parentId === levelId, + ) + +export const ADD_FENCE_CASES: AgentToolCase[] = [ + { + name: 'one fence per side along the points, privacy panels unless asked', + tool: 'add_fence', + scene: storeysScene, + input: { + levelId: 'level_ground', + points: [ + [0, 0], + [8, 0], + [8, 6], + ], + }, + expect: { + result: { ok: true, count: 2, levelId: 'level_ground', length: 14 }, + check: (_, nodes) => { + const fences = fencesOn(nodes, 'level_ground') + return fences.length === 2 && fences.every((fence) => fence.style === 'privacy') + ? [] + : [`${fences.length} fences on the ground floor, styles ${fences.map((f) => f.style)}`] + }, + }, + }, + { + name: "closed runs back to the first point, a lot's boundary", + tool: 'add_fence', + scene: storeysScene, + input: { + levelId: 'level_ground', + closed: true, + style: 'picket', + points: [ + [0, 0], + [10, 0], + [10, 10], + [0, 10], + ], + }, + expect: { result: { ok: true, count: 4, length: 40 } }, + }, + { + name: 'a side shorter than 10 cm is refused, nothing built', + tool: 'add_fence', + scene: storeysScene, + input: { + levelId: 'level_ground', + points: [ + [0, 0], + [4, 0], + [4, 0.05], + ], + }, + expect: { refusal: 'fence_too_short' }, + }, + { + name: 'a roof level takes no fence', + tool: 'add_fence', + scene: storeysScene, + input: { + levelId: 'level_roof', + points: [ + [0, 0], + [4, 0], + ], + }, + expect: { refusal: 'roof_level', mentions: ['level_roof'] }, + }, + { + name: 'a finish the library lacks is refused with the nearest', + tool: 'add_fence', + scene: storeysScene, + input: { + levelId: 'level_ground', + materialPreset: 'library:unobtainium', + points: [ + [0, 0], + [4, 0], + ], + }, + expect: { refusal: 'unknown_material' }, + }, +] + export const ADD_LEVEL_CASES: AgentToolCase[] = [ { name: 'above the highest level of the viewed building, at the default storey height', diff --git a/packages/core/src/agent-operations/add-fence.ts b/packages/core/src/agent-operations/add-fence.ts new file mode 100644 index 0000000000..2471744dd2 --- /dev/null +++ b/packages/core/src/agent-operations/add-fence.ts @@ -0,0 +1,69 @@ +import { refuse } from '../agent-tools/refusal' +import { FenceNode } from '../schema' +import { refuseRoofLevel } from './add-wall' +import { type LevelTargetInput, targetLevel } from './level-target' +import { finishSurface, requireMaterialRef } from './material-refs' +import type { AgentOperation } from './types' + +type Pt = [number, number] + +/** A fence side shorter than this is a slip, not a fence. */ +const FENCE_MIN_SIDE = 0.1 + +type AddFenceInput = LevelTargetInput & { + points: number[][] + closed?: boolean + height?: number + style?: FenceNode['style'] + baseStyle?: FenceNode['baseStyle'] + materialPreset?: string +} + +const round = (value: number) => Math.round(value * 100) / 100 + +/** + * `add_fence`: one fence per side along the points, privacy unless asked (one made with no style + * came out as open rails). Fences stand on the level, as the editor's fence tool puts them. + */ +export const addFence: AgentOperation = (nodes, input, context) => { + const level = targetLevel(nodes, input, context) + refuseRoofLevel(nodes, level.id, 'a fence') + const materialPreset = + input.materialPreset === undefined + ? undefined + : requireMaterialRef(input.materialPreset, 'materialPreset', finishSurface('fence')) + const points = input.points as Pt[] + const sides: [Pt, Pt][] = points.slice(1).map((point, i) => [points[i]!, point]) + if (input.closed && points.length > 2) sides.push([points.at(-1)!, points[0]!]) + const short = sides.find(([a, b]) => Math.hypot(b[0] - a[0], b[1] - a[1]) < FENCE_MIN_SIDE) + if (short) + refuse( + 'fence_too_short', + `The side from [${short[0]}] to [${short[1]}] is shorter than ${FENCE_MIN_SIDE * 100} cm: drop the repeated point.`, + { start: short[0], end: short[1] }, + ) + const first = Object.values(nodes).filter((node) => node.type === 'fence').length + const fences = sides.map(([start, end], i) => + FenceNode.parse({ + name: `Fence ${first + i + 1}`, + parentId: level.id, + start, + end, + style: input.style ?? 'privacy', + ...(input.height === undefined ? {} : { height: input.height }), + ...(input.baseStyle === undefined ? {} : { baseStyle: input.baseStyle }), + ...(materialPreset === undefined ? {} : { materialPreset }), + }), + ) + const length = sides.reduce((sum, [a, b]) => sum + Math.hypot(b[0] - a[0], b[1] - a[1]), 0) + return { + result: { + ok: true, + count: fences.length, + fenceIds: fences.map((fence) => fence.id), + levelId: level.id, + length: round(length), + }, + changes: { create: fences.map((node) => ({ node, parentId: level.id })) }, + } +} diff --git a/packages/core/src/agent-operations/index.ts b/packages/core/src/agent-operations/index.ts index 4f644b44d8..1c9d714944 100644 --- a/packages/core/src/agent-operations/index.ts +++ b/packages/core/src/agent-operations/index.ts @@ -1,5 +1,6 @@ import { AGENT_TOOL_CONTRACTS, BATCHABLE_TOOLS } from '../agent-tools' import { addColumn } from './add-column' +import { addFence } from './add-fence' import { addLevel } from './add-level' import { addWall } from './add-wall' import { createBatchOperation } from './batch' @@ -28,6 +29,7 @@ import { verifyScene } from './verify-scene' export * from './achieved' export * from './add-column' +export * from './add-fence' export * from './add-level' export * from './add-object' export * from './add-wall' @@ -82,6 +84,7 @@ const SINGLE_OPERATIONS = { verify_scene: verifyScene, set_checkpoint: setCheckpoint, add_wall: addWall, + add_fence: addFence, add_corner_window: addCornerWindow, merge_windows: mergeWindowsOperation, add_level: addLevel, diff --git a/packages/core/src/agent-tools/batch.ts b/packages/core/src/agent-tools/batch.ts index e0222a1013..aca4a59c0e 100644 --- a/packages/core/src/agent-tools/batch.ts +++ b/packages/core/src/agent-tools/batch.ts @@ -17,6 +17,7 @@ export const BATCHABLE_TOOLS = [ 'find_by_type', 'create_roof', 'add_wall', + 'add_fence', 'add_level', 'create_stair', 'add_corner_window', diff --git a/packages/core/src/agent-tools/fences.ts b/packages/core/src/agent-tools/fences.ts new file mode 100644 index 0000000000..31ed6557cd --- /dev/null +++ b/packages/core/src/agent-tools/fences.ts @@ -0,0 +1,39 @@ +import { z } from 'zod' +import { FenceBaseStyle, FenceStyle } from '../schema/nodes/fence-types' +import { levelTarget } from './levels' +import { measurement } from './measurement' + +export const addFenceTool = { + name: 'add_fence', + title: 'Add fence', + description: + "Add a fence along points on a level: one fence per side, a whole run or a lot's boundary (closed) in one call. Privacy (solid panels) by default; picket for a picket fence, slat for vertical slats, rail for open rails, horizontal for cladding boards, guard for a deck's barrier. Fences stand on the level, as the editor's fence tool puts them. Refused with a code: a side shorter than 10 cm (fence_too_short), a roof level (roof_level).", + input: { + points: z + .array(z.array(z.number()).length(2)) + .min(2) + .max(64) + .describe( + '[x, z] points in level coordinates (metres), the fence running through them in order.', + ), + closed: z + .boolean() + .optional() + .describe("Close the run back to the first point, as a lot's boundary."), + height: measurement('length', 'm', { + positive: true, + description: 'Fence height (default 1.8 m).', + }).optional(), + style: FenceStyle.optional().describe( + 'slat | rail | privacy | horizontal | guard | picket (default privacy).', + ), + baseStyle: FenceBaseStyle.optional().describe( + 'grounded (default: on the ground), floating (raised off it) or raised (on a low plinth).', + ), + materialPreset: z + .string() + .optional() + .describe('A library material (library:, as search_assets names them).'), + ...levelTarget, + }, +} diff --git a/packages/core/src/agent-tools/index.ts b/packages/core/src/agent-tools/index.ts index 745c0e1b54..580e278c38 100644 --- a/packages/core/src/agent-tools/index.ts +++ b/packages/core/src/agent-tools/index.ts @@ -8,6 +8,7 @@ import { addColumnTool } from './columns' import type { AgentToolContract } from './contract' import { addCornerWindowTool } from './corner-window' import { createRoomTool } from './create-room' +import { addFenceTool } from './fences' import { findByTypeTool } from './find-by-type' import { furnishRoomTool } from './furnish-room' import { @@ -41,6 +42,7 @@ export * from './columns' export * from './contract' export * from './corner-window' export * from './create-room' +export * from './fences' export * from './find-by-type' export * from './furnish-room' export * from './hosted-services' @@ -82,6 +84,7 @@ export const AGENT_TOOL_CONTRACTS = [ verifySceneTool, setCheckpointTool, addWallTool, + addFenceTool, addCornerWindowTool, mergeWindowsTool, addLevelTool, diff --git a/packages/core/src/schema/nodes/fence-types.ts b/packages/core/src/schema/nodes/fence-types.ts new file mode 100644 index 0000000000..0778b6330e --- /dev/null +++ b/packages/core/src/schema/nodes/fence-types.ts @@ -0,0 +1,24 @@ +import { z } from 'zod' + +// Fence enums, in a file with no other dependency: agent tool contracts validate against them +// without loading the node schemas. + +/** + * 'guard' — a deck guard built the way the AWC's Deck Construction Guide + * (DCA 6) draws one: 4x4 posts no more than `postSpacing` apart, a 2x6 cap + * rail flat on top, a 2x4 top rail on edge under it, and the infill + * `guardInfill` chooses — 2x2 balusters on a 2x4 bottom rail at a ≤ 4 in + * clear gap (IRC R312.1.3), ½ in cables `slatGap` apart, or horizontal + * boards `slatGap` apart. `startPost` / `endPost` false leave that end's + * post out so the rails die into a post already standing there (a porch's + * 6x6); `postThrough` runs the posts past the cap with a cap of their own. + */ +export const FenceStyle = z.enum(['slat', 'rail', 'privacy', 'horizontal', 'guard', 'picket']) +/** + * 'grounded' — a kickboard on the ground; 'floating' — no base, the panel + * held `groundClearance` up on posts that reach the ground; 'raised' — the + * base is a BOTTOM RAIL held `groundClearance` above the ground with the + * infill ending on it, the posts to the ground: a deck guard (IRC R312 — + * pickets between a top and a bottom rail, the gap under the rail below 4 in). + */ +export const FenceBaseStyle = z.enum(['floating', 'grounded', 'raised']) diff --git a/packages/core/src/schema/nodes/fence.ts b/packages/core/src/schema/nodes/fence.ts index d550f30dfb..f689470995 100644 --- a/packages/core/src/schema/nodes/fence.ts +++ b/packages/core/src/schema/nodes/fence.ts @@ -2,27 +2,10 @@ import dedent from 'dedent' import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' import { MaterialSchema } from '../material' +import { FenceBaseStyle, FenceStyle } from './fence-types' -/** - * 'guard' — a deck guard built the way the AWC's Deck Construction Guide - * (DCA 6) draws one: 4x4 posts no more than `postSpacing` apart, a 2x6 cap - * rail flat on top, a 2x4 top rail on edge under it, and the infill - * `guardInfill` chooses — 2x2 balusters on a 2x4 bottom rail at a ≤ 4 in - * clear gap (IRC R312.1.3), ½ in cables `slatGap` apart, or horizontal - * boards `slatGap` apart. `startPost` / `endPost` false leave that end's - * post out so the rails die into a post already standing there (a porch's - * 6x6); `postThrough` runs the posts past the cap with a cap of their own. - */ -export const FenceStyle = z.enum(['slat', 'rail', 'privacy', 'horizontal', 'guard', 'picket']) +export { FenceBaseStyle, FenceStyle } from './fence-types' export const FenceGuardInfill = z.enum(['balusters', 'cable', 'boards']) -/** - * 'grounded' — a kickboard on the ground; 'floating' — no base, the panel - * held `groundClearance` up on posts that reach the ground; 'raised' — the - * base is a BOTTOM RAIL held `groundClearance` above the ground with the - * infill ending on it, the posts to the ground: a deck guard (IRC R312 — - * pickets between a top and a bottom rail, the gap under the rail below 4 in). - */ -export const FenceBaseStyle = z.enum(['floating', 'grounded', 'raised']) export const FencePostCap = z.enum(['none', 'flat', 'pyramid']) export const FenceSurfaceMode = z.enum(['auto', 'selected', 'level']) export const FenceTransitionMode = z.enum(['slope', 'step', 'break']) diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 563631eee4..1376fa480a 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -370,6 +370,7 @@ an optional `height` and uses the same default. | `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 }`. | `{ patches: Patch[] }` | `{ applied: number, notes?: string[] }` | | `add_level` | Add an empty level to a building, as the editor does: above the highest, or below the lowest for a basement. Omit `buildingId` for the scene's only building. | `{ buildingId?, position?, name?, height? }` | `{ levelId, buildingId, floorIndex, height }`; 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` | | `place_items` | Place catalog items in one call, each placed or refused on its own, at level `(x, z)`: on the level's floor, or on the host each names (`targetNodeId`): a wall (`y`, the height of its bottom, required), a ceiling, or an item standing on the floor. A floor item in a door's clearance is refused `blocks_door` (naming the door and a spot that clears every door), one its room cannot hold in any turn `too_large_for_room`. | `{ items: [{ assetId, x, z, y?, targetNodeId?, rotation? }], levelId? }` | `{ levelId, items: [{ ok, itemId?, hostId?, restingOn?, code? }] }`; per-item refusals `asset_not_found`, `outside_rooms`, `blocks_door`, `too_large_for_room`, `height_required`, `item_too_tall`, `unsupported_host`, `host_not_found`, `host_not_on_level` | | `place_design` | Create one design (procedural item recipe, object or JSON string) that passes `validate_design`. Its mounting picks the host: level/slab/zone or a design surface (`surfaceId`), a straight wall face, or a ceiling. Create-only, one undo step, inline designs up to 24 KiB, with coded refusals. | `{ design, hostId, position, rotation?, side?, surfaceId?, parameters?, slots?, name?, id? }` | `{ designId, parentId, surfaceId }` | | `set_zone` | Create a zone/room polygon on a level. | `{ levelId, polygon, label, properties? }` | `{ zoneId }` | diff --git a/packages/mcp/src/tools/read-tool-annotations.test.ts b/packages/mcp/src/tools/read-tool-annotations.test.ts index 999a0516dd..54059be876 100644 --- a/packages/mcp/src/tools/read-tool-annotations.test.ts +++ b/packages/mcp/src/tools/read-tool-annotations.test.ts @@ -62,6 +62,7 @@ const TOOL_POLICIES = [ tools: [ 'add_corner_window', 'add_door', + 'add_fence', 'add_level', 'add_wall', 'add_window', diff --git a/packages/mcp/src/tools/shared-tools.ts b/packages/mcp/src/tools/shared-tools.ts index 8ea9ea249d..fb4c8f143b 100644 --- a/packages/mcp/src/tools/shared-tools.ts +++ b/packages/mcp/src/tools/shared-tools.ts @@ -14,6 +14,7 @@ import { type AgentToolAnnotations, type AgentToolContract, addCornerWindowTool, + addFenceTool, addLevelTool, addWallTool, createRoofTool, @@ -260,6 +261,11 @@ const SHARED_TOOLS: SharedTool[] = [ operation: AGENT_OPERATIONS.add_wall, annotations: ADDITIVE_TOOL_ANNOTATIONS, }, + { + contract: addFenceTool, + operation: AGENT_OPERATIONS.add_fence, + annotations: ADDITIVE_TOOL_ANNOTATIONS, + }, { contract: addCornerWindowTool, operation: AGENT_OPERATIONS.add_corner_window, diff --git a/plugin-evals/tool-annotation-justifications.json b/plugin-evals/tool-annotation-justifications.json index d8ab5754f6..44d8e66686 100644 --- a/plugin-evals/tool-annotation-justifications.json +++ b/plugin-evals/tool-annotation-justifications.json @@ -45,6 +45,19 @@ "openWorldHint": "It operates on the connected Pascal scene, project, and configured catalog data without accessing the public internet." } }, + { + "name": "add_fence", + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "openWorldHint": false + }, + "justifications": { + "readOnlyHint": "It is not read-only because it adds fence nodes along points on a level of the connected Pascal scene.", + "destructiveHint": "Its intended operation adds new modeled state without deleting or replacing existing Pascal scene or project state.", + "openWorldHint": "It operates on the connected Pascal scene without accessing the public internet." + } + }, { "name": "add_level", "annotations": { diff --git a/scripts/openai-tool-annotation-policy.ts b/scripts/openai-tool-annotation-policy.ts index b99216cc79..ee03e24f74 100644 --- a/scripts/openai-tool-annotation-policy.ts +++ b/scripts/openai-tool-annotation-policy.ts @@ -15,6 +15,7 @@ const policy = ( export const EXPECTED_OPENAI_TOOL_ANNOTATIONS = { add_door: policy(false, false, false), + add_fence: policy(false, false, false), add_level: policy(false, false, false), add_wall: policy(false, false, false), add_corner_window: policy(false, false, false), From 45803046266224b47686e7dc87992fe34cb88f2b Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Mon, 12 Oct 2026 16:15:31 -0400 Subject: [PATCH 02/16] feat: roofs live on the building's roof level A roof made on the ground floor's own level moved with that floor in the exploded view and covered its plan in solo view (the owner's report on the Architect chat). The chat, the MCP and the hand roof tool each placed roofs by a rule of their own. There is now one rule, in one core service (services/roof-level.ts): a roof lives on the level directly above the storey whose walls carry it, and over the top storey that is the building's roof level (metadata.role 'roof', named Roof), kept above every storey. - planRoofLevel: the level above the storey, or the roof level to reuse, declare (an empty level, or one that only holds roofs) or create; roofsOver, storeyInsertIndex and isRoofLevel beside it. - The shared create_roof (editor#1024) puts its roof on the roof level, following the storey's wall tops (support 'walls', the eave resolved as the editor's follow would). It refuses a second roof over a storey (roof_exists) and a storey with another above it (storey_above). - add_level and the editor's "add a floor" (level selector, settings, site tree, command palette) put a new storey under the roof level, which moves up one in the same step. - The hand roof tool follows the same rule: a room footprint, a rectangle drawn on a walled storey and a cone over a curved wall go on the level above the walls; the ghost is drawn where the roof lands. - verify_scene reports a roof level below a storey (roof_level_not_on_top, replacing roof_level_below_reference); existing scenes are not migrated. - Unpainted gables on a roof level take the cladding of the storey below. - RoofType moves to a dependency-free schema module for the contract. Built on Adam's create_roof from pascalorg/editor#1024 (Bundle 2). Co-authored-by: Adam NAILI Co-Authored-By: Claude Opus 5.5 --- .../agent-operations/__fixtures__/cases.ts | 2 + .../__fixtures__/roof-cases.ts | 202 ++++++++++++ .../__fixtures__/structure-cases.ts | 10 + .../__fixtures__/verify-scene-cases.ts | 27 ++ .../core/src/agent-operations/add-level.ts | 15 +- .../core/src/agent-operations/add-wall.ts | 5 +- .../core/src/agent-operations/create-roof.ts | 301 ++++++------------ packages/core/src/agent-operations/index.ts | 1 + .../src/agent-operations/scene-queries.ts | 6 +- .../core/src/agent-operations/verify-scene.ts | 19 +- packages/core/src/agent-tools/index.ts | 1 + packages/core/src/agent-tools/levels.ts | 6 +- packages/core/src/agent-tools/roofs.ts | 22 +- .../core/src/contracts/reference-inventory.ts | 9 +- .../core/src/schema/nodes/roof-segment.ts | 14 +- packages/core/src/schema/nodes/roof-types.ts | 16 + packages/core/src/services/index.ts | 7 + packages/core/src/services/roof-level.test.ts | 142 +++++++++ packages/core/src/services/roof-level.ts | 143 +++++++++ .../ui/command-palette/editor-commands.tsx | 15 +- .../components/ui/floating-level-selector.tsx | 12 +- .../sidebar/panels/settings-panel/index.tsx | 13 +- .../panels/site-panel/building-tree-node.tsx | 14 +- .../ui/sidebar/panels/site-panel/index.tsx | 13 +- packages/editor/src/lib/add-storey.test.ts | 58 ++++ packages/editor/src/lib/add-storey.ts | 29 ++ packages/mcp/README.md | 6 +- packages/mcp/src/resources/agent-guide.ts | 2 +- .../mcp/src/tools/construction-tools.test.ts | 2 +- packages/mcp/src/tools/construction-tools.ts | 12 +- packages/mcp/src/tools/set-zone.ts | 9 +- packages/nodes/src/roof/conical-roof.ts | 32 +- packages/nodes/src/roof/roof-level-target.ts | 89 ++++++ packages/nodes/src/roof/tool.test.ts | 27 +- packages/nodes/src/roof/tool.tsx | 91 +++--- packages/nodes/src/wall/definition.test.ts | 14 +- .../src/systems/roof/roof-materials.test.ts | 62 ++++ .../viewer/src/systems/roof/roof-materials.ts | 23 +- .../tool-annotation-justifications.json | 2 +- skills/pascal-3d/SKILL.md | 4 +- wiki/architecture/vertical-model.md | 18 +- 41 files changed, 1098 insertions(+), 397 deletions(-) create mode 100644 packages/core/src/agent-operations/__fixtures__/roof-cases.ts create mode 100644 packages/core/src/schema/nodes/roof-types.ts create mode 100644 packages/core/src/services/roof-level.test.ts create mode 100644 packages/core/src/services/roof-level.ts create mode 100644 packages/editor/src/lib/add-storey.test.ts create mode 100644 packages/editor/src/lib/add-storey.ts create mode 100644 packages/nodes/src/roof/roof-level-target.ts create mode 100644 packages/viewer/src/systems/roof/roof-materials.test.ts diff --git a/packages/core/src/agent-operations/__fixtures__/cases.ts b/packages/core/src/agent-operations/__fixtures__/cases.ts index 768f90d5a0..db930ece8e 100644 --- a/packages/core/src/agent-operations/__fixtures__/cases.ts +++ b/packages/core/src/agent-operations/__fixtures__/cases.ts @@ -20,6 +20,7 @@ import { import { CREATE_ROOM_CASES, FURNISH_ROOM_CASES } from './build-room-cases' import { PLACE_ITEMS_CASES } from './place-items-cases' import { FIND_BY_TYPE_CASES } from './find-by-type-cases' +import { CREATE_ROOF_CASES } from './roof-cases' import { ROOM_CASES } from './room-cases' import { SEARCH_ASSETS_CASES } from './search-assets-cases' import { CORNER_WINDOW_CASES } from './corner-window-cases' @@ -900,6 +901,7 @@ export const AGENT_TOOL_CASES: readonly AgentToolCase[] = [ ...SEARCH_ASSETS_CASES, ...ADD_WALL_CASES, ...ADD_FENCE_CASES, + ...CREATE_ROOF_CASES, ...ADD_LEVEL_CASES, ...CREATE_STAIR_CASES, ...PLACE_ITEMS_CASES, diff --git a/packages/core/src/agent-operations/__fixtures__/roof-cases.ts b/packages/core/src/agent-operations/__fixtures__/roof-cases.ts new file mode 100644 index 0000000000..e81b2b2be1 --- /dev/null +++ b/packages/core/src/agent-operations/__fixtures__/roof-cases.ts @@ -0,0 +1,202 @@ +import { + type AnyNode, + BuildingNode, + LevelNode, + RoofNode, + RoofSegmentNode, + WallNode, + ZoneNode, +} from '../../schema' +import type { AgentToolCase, SceneGraph } from './cases' + +/** + * `create_roof`, one tool where the chat and the MCP had one each with two level rules: the roof + * over a building's top storey goes on its roof level, the level directly above (the owner: a + * roof made on the ground floor's level blocked the exploded view), and stands on the storey's + * wall tops. + * + * A building of 2.5 m storeys, each an 8 × 6 m room walled round; the top one is `walls` tall. + */ + +type Pt = [number, number] +const ROOM: Pt[] = [ + [0, 0], + [8, 0], + [8, 6], + [0, 6], +] + +const graph = (...nodes: AnyNode[]): SceneGraph => ({ + nodes: Object.fromEntries(nodes.map((node) => [node.id, node])), + rootNodeIds: nodes.filter((node) => node.type === 'building').map((node) => node.id), +}) + +function storey(id: string, index: number, wallHeight = 2.5) { + const walls = ROOM.map((start, i) => + WallNode.parse({ + id: `wall_${id}_${i}`, + parentId: id, + start, + end: ROOM[(i + 1) % ROOM.length]!, + height: wallHeight, + }), + ) + const zone = ZoneNode.parse({ id: `zone_${id}`, parentId: id, name: 'Hall', polygon: ROOM }) + const level = LevelNode.parse({ + id, + parentId: 'building_r', + level: index, + height: 2.5, + children: [...walls.map((wall) => wall.id), zone.id], + }) + return [level, ...walls, zone] +} + +function house({ + storeys = 1, + topWalls = 2.5, + above, +}: { + storeys?: number + topWalls?: number + /** What stands above the top storey: an empty level, or a roof level with its roof. */ + above?: 'empty' | 'roof' +} = {}) { + return (): SceneGraph => { + const ids = Array.from({ length: storeys }, (_, i) => (i === 0 ? 'level_g' : `level_${i}`)) + const nodes = ids.flatMap((id, i) => storey(id, i, i === storeys - 1 ? topWalls : 2.5)) + const extra: AnyNode[] = [] + if (above === 'empty') + extra.push(LevelNode.parse({ id: 'level_e', parentId: 'building_r', level: storeys })) + if (above === 'roof') { + const segment = RoofSegmentNode.parse({ id: 'rseg_old', parentId: 'roof_old' }) + extra.push( + LevelNode.parse({ + id: 'level_r', + parentId: 'building_r', + level: storeys, + name: 'Roof', + children: ['roof_old'], + metadata: { role: 'roof' }, + }), + RoofNode.parse({ id: 'roof_old', parentId: 'level_r', children: [segment.id] }), + segment, + ) + } + const building = BuildingNode.parse({ + id: 'building_r', + children: [...ids, ...extra.filter((node) => node.type === 'level').map((node) => node.id)], + }) + return graph(building, ...nodes, ...extra) + } +} + +const roofOf = (result: Record, nodes: Readonly>) => + nodes[result.roofId as string] as RoofNode | undefined + +/** The roof sits on a declared roof level directly above `storeyId`, following its walls at `y`. */ +const onRoofLevel = + (storeyId: string, y: number) => + (result: Record, nodes: Readonly>) => { + const roof = roofOf(result, nodes) + const level = roof?.parentId ? nodes[roof.parentId] : undefined + const covered = nodes[storeyId] + const problems: string[] = [] + if (level?.type !== 'level' || covered?.type !== 'level') + return [`roof ${roof?.id} is not on a level`] + if (level.metadata.role !== 'roof') problems.push(`${level.id} is not declared a roof level`) + if (level.level !== covered.level + 1) problems.push(`${level.id} is not right above ${storeyId}`) + if (roof?.support?.kind !== 'walls') problems.push('the roof does not follow its walls') + if (Math.abs((roof?.position[1] ?? Number.NaN) - y) > 1e-6) + problems.push(`the eave is at ${roof?.position[1]}, not ${y} over the roof level`) + return problems + } + +export const CREATE_ROOF_CASES: AgentToolCase[] = [ + { + name: 'over a one-storey house, on a new Roof level above it, on the wall tops', + tool: 'create_roof', + scene: house(), + input: {}, + expect: { + result: { coveredLevelId: 'level_g', roofType: 'gable' }, + check: (result, nodes) => [ + ...onRoofLevel('level_g', 0)(result, nodes), + ...(result.createdRoofLevelId === result.roofLevelId ? [] : ['no Roof level was made']), + ...((nodes[result.roofLevelId as string] as LevelNode | undefined)?.name === 'Roof' + ? [] + : ['the roof level is not named Roof']), + ], + }, + }, + { + name: 'walls taller than their storey lift the eave to their tops', + tool: 'create_roof', + scene: house({ topWalls: 3 }), + input: {}, + expect: { result: { coveredLevelId: 'level_g' }, check: onRoofLevel('level_g', 0.5) }, + }, + { + name: 'no level named: the top storey, whichever floor the person is viewing', + tool: 'create_roof', + scene: house({ storeys: 2 }), + input: {}, + context: { activeLevelId: 'level_g' }, + surfaces: ['core', 'chat'], + expect: { result: { coveredLevelId: 'level_1' }, check: onRoofLevel('level_1', 0) }, + }, + { + name: 'an empty level above the top storey becomes its roof level', + tool: 'create_roof', + scene: house({ above: 'empty' }), + input: { levelId: 'level_g' }, + expect: { + result: { roofLevelId: 'level_e', createdRoofLevelId: null }, + after: { level_e: { metadata: { role: 'roof' }, name: 'Roof' } }, + check: onRoofLevel('level_g', 0), + }, + }, + { + name: 'a storey that has a roof already is refused, naming it', + tool: 'create_roof', + scene: house({ above: 'roof' }), + input: {}, + expect: { refusal: 'roof_exists', mentions: ['roof_old', 'delete_node'] }, + }, + { + name: 'the ground floor of two storeys is refused: a roof caps the top one', + tool: 'create_roof', + scene: house({ storeys: 2 }), + input: { levelId: 'level_g' }, + expect: { refusal: 'storey_above', mentions: ['level_1'] }, + }, + { + name: 'a roof level named stands for the storey under it', + tool: 'create_roof', + scene: house({ above: 'empty' }), + input: { levelId: 'level_e' }, + expect: { result: { coveredLevelId: 'level_g', roofLevelId: 'level_e' } }, + }, + { + name: 'a rectangle of its own needs no rooms', + tool: 'create_roof', + scene: () => + graph( + BuildingNode.parse({ id: 'building_r', children: ['level_g'] }), + LevelNode.parse({ id: 'level_g', parentId: 'building_r', level: 0, height: 2.5 }), + ), + input: { width: 4, depth: 3, center: [2, 1.5] }, + expect: { result: { coveredLevelId: 'level_g' }, check: onRoofLevel('level_g', 0) }, + }, + { + name: 'with no rooms anywhere, a rectangle is asked for', + tool: 'create_roof', + scene: () => + graph( + BuildingNode.parse({ id: 'building_r', children: ['level_g'] }), + LevelNode.parse({ id: 'level_g', parentId: 'building_r', level: 0, height: 2.5 }), + ), + input: {}, + expect: { refusal: 'no_rooms', mentions: ['width and depth'] }, + }, +] diff --git a/packages/core/src/agent-operations/__fixtures__/structure-cases.ts b/packages/core/src/agent-operations/__fixtures__/structure-cases.ts index e0167a5365..02d66344da 100644 --- a/packages/core/src/agent-operations/__fixtures__/structure-cases.ts +++ b/packages/core/src/agent-operations/__fixtures__/structure-cases.ts @@ -271,6 +271,16 @@ export const ADD_LEVEL_CASES: AgentToolCase[] = [ result: { ok: true, buildingId: 'building_house', floorIndex: 2, name: 'Attic', height: 2.5 }, }, }, + { + name: 'on top of the storeys, under the roof level, which moves up one', + tool: 'add_level', + scene: storeysScene, + input: { buildingId: 'building_roofed' }, + expect: { + result: { ok: true, floorIndex: 1, shiftedLevelIds: ['level_roof'] }, + after: { level_roof: { level: 2 } }, + }, + }, { name: 'below the lowest level for a basement, at the height asked', tool: 'add_level', diff --git a/packages/core/src/agent-operations/__fixtures__/verify-scene-cases.ts b/packages/core/src/agent-operations/__fixtures__/verify-scene-cases.ts index 0a0de4b036..6781c8b8aa 100644 --- a/packages/core/src/agent-operations/__fixtures__/verify-scene-cases.ts +++ b/packages/core/src/agent-operations/__fixtures__/verify-scene-cases.ts @@ -273,6 +273,33 @@ export const VERIFY_SCENE_CASES: AgentToolCase[] = [ ), { contains: ['roof_level_no_roof'] }, ), + verify( + 'a roof level below a storey of its building is reported', + () => + scene( + building(), + level('level_0', 0), + level('level_roof', 1, { metadata: { role: 'roof' } }), + level('level_2', 2), + ...room('level_0', 'ground'), + ...room('level_2', 'upper'), + ...stairOn('level_0'), + RoofNode.parse({ id: 'roof_main', parentId: 'level_roof' }), + ), + { contains: ['roof_level_not_on_top'], mentions: ['level_2'] }, + ), + verify( + 'a roof level on top of its storeys is quiet', + () => + scene( + building(), + level('level_0', 0), + level('level_roof', 1, { metadata: { role: 'roof' } }), + ...room('level_0', 'ground'), + RoofNode.parse({ id: 'roof_main', parentId: 'level_roof' }), + ), + { lacks: ['roof_level_not_on_top'] }, + ), verify( 'a roof on a storey is reported', () => diff --git a/packages/core/src/agent-operations/add-level.ts b/packages/core/src/agent-operations/add-level.ts index eaa6c5f5c7..6ae79ef24a 100644 --- a/packages/core/src/agent-operations/add-level.ts +++ b/packages/core/src/agent-operations/add-level.ts @@ -1,6 +1,7 @@ import { refuse } from '../agent-tools/refusal' import { BuildingNode, LevelNode, SiteNode } from '../schema' import { DEFAULT_LEVEL_HEIGHT } from '../services/level-height' +import { storeyInsertIndex } from '../services/roof-level' import { levelsOf } from './scene-queries' import type { AgentContext, AgentOperation, SceneNodes } from './types' @@ -79,7 +80,10 @@ function freshStart(nodes: SceneNodes, input: AddLevelInput) { } } -/** `add_level`: an empty level over the building's highest, or under its lowest — the editor's +. */ +/** + * `add_level`: an empty level over the building's top storey, under its roof level (which moves up + * one), or under its lowest level — the editor's +. + */ export const addLevel: AgentOperation = (nodes, input, context) => { if (!(input.buildingId || Object.values(nodes).some((node) => node.type === 'building'))) return freshStart(nodes, input) @@ -88,7 +92,9 @@ export const addLevel: AgentOperation = (nodes, input, context) = .filter((level) => level.parentId === building.id || building.children.includes(level.id)) .map((level) => level.level) const below = input.position === 'below' - const floorIndex = !floors.length ? 0 : below ? Math.min(...floors) - 1 : Math.max(...floors) + 1 + const above = storeyInsertIndex(nodes, building.id) + const floorIndex = below && floors.length ? Math.min(...floors) - 1 : above.floorIndex + const shift = below ? [] : above.shift const level = LevelNode.parse({ parentId: building.id, level: floorIndex, @@ -104,8 +110,9 @@ export const addLevel: AgentOperation = (nodes, input, context) = floorIndex, ...(level.name ? { name: level.name } : {}), height: level.height, - message: `Added ${level.name ?? `level ${floorIndex}`} ${below ? 'below' : 'above'} the existing levels of ${building.name ?? building.id}.`, + ...(shift.length ? { shiftedLevelIds: shift.map(({ id }) => id) } : {}), + message: `Added ${level.name ?? `level ${floorIndex}`} ${below ? 'below the existing levels' : shift.length ? 'on top of the storeys, under the roof level' : 'above the existing levels'} of ${building.name ?? building.id}.`, }, - changes: { create: [{ node: level, parentId: building.id }] }, + changes: { create: [{ node: level, parentId: building.id }], update: shift }, } } diff --git a/packages/core/src/agent-operations/add-wall.ts b/packages/core/src/agent-operations/add-wall.ts index 4a95d9a71c..4138da26e1 100644 --- a/packages/core/src/agent-operations/add-wall.ts +++ b/packages/core/src/agent-operations/add-wall.ts @@ -1,9 +1,9 @@ import { refuse } from '../agent-tools/refusal' import { WallNode } from '../schema' +import { isRoofLevel } from '../services/roof-level' import { normalizeWallCurveOffset } from '../systems/wall/wall-curve' import { WALL_MIN_LENGTH } from '../systems/wall/wall-topology' import { type LevelTargetInput, targetLevel } from './level-target' -import { levelRole } from './scene-queries' import type { AgentOperation, SceneNodes } from './types' type Pt = [number, number] @@ -17,8 +17,7 @@ type AddWallInput = LevelTargetInput & { /** A level declared as the roof holds the roof, not rooms: walls and stairs stay off it. */ export function refuseRoofLevel(nodes: SceneNodes, levelId: string, what: string) { - const level = nodes[levelId] - if (level && levelRole(nodes, level).metadataRole === 'roof') + if (isRoofLevel(nodes[levelId])) refuse( 'roof_level', `${levelId} is a roof level, not a storey: ${what} belongs on an occupied storey. Add one with add_level if the building needs it.`, diff --git a/packages/core/src/agent-operations/create-roof.ts b/packages/core/src/agent-operations/create-roof.ts index b0674f019d..d3c7ecaf87 100644 --- a/packages/core/src/agent-operations/create-roof.ts +++ b/packages/core/src/agent-operations/create-roof.ts @@ -1,15 +1,15 @@ import { refuse } from '../agent-tools/refusal' -import { gapBridges } from '../building/wall-gaps' -import { exteriorWallLoops, exteriorWallOutlines } from '../lib/exterior-wall-loops' import { type AnyNode, + type AnyNodeId, getActiveRoofHeight, - LevelNode, RoofNode, RoofSegmentNode, type RoofType, - type WallNode, } from '../schema' +import { planRoofLevel, roofsOver } from '../services/roof-level' +import { resolveRoofElevation } from '../systems/roof/roof-elevation' +import { applySceneChanges } from './apply-changes' import { requireLevel } from './level-target' import { finishSurface, requireMaterialRef } from './material-refs' import { buildingFrame, type RoofRectangle, roofRectangles } from './roof-footprint' @@ -36,10 +36,17 @@ type CreateRoofInput = { name?: string } -/** A storey a roof can cover: rooms, or walls whose outline closes once their gaps are bridged. */ -const hasFootprint = (nodes: SceneNodes, levelId: string) => - Object.values(nodes).some((node) => node.type === 'zone' && node.parentId === levelId) || - wallOutline(nodes, levelId).length > 0 +const isStorey = (nodes: SceneNodes, level: Level) => levelRole(nodes, level).role !== 'roof' + +/** A storey's floor plates, else its rooms: the footprint a roof covers. */ +function footprintPolygons(nodes: SceneNodes, storeyId: string): Pt[][] { + const on = (type: 'slab' | 'zone') => + Object.values(nodes).filter( + (node) => node.type === type && node.parentId === storeyId, + ) as (AnyNode & { polygon: Pt[] })[] + const plates = on('slab') + return (plates.length ? plates : on('zone')).map((node) => node.polygon) +} const buildingLevels = (nodes: SceneNodes, level: Level) => levelsOf(nodes).filter((other) => other.parentId === level.parentId) @@ -55,10 +62,11 @@ function fasciaFields({ fasciaHeight, fasciaMaterialPreset }: CreateRoofInput) { } /** - * The storey a roof covers: the one named when it has rooms, else the highest storey of its - * building with rooms; none named, the top storey with rooms of the building in view. A rectangle - * of its own needs no rooms: the storey named, else the top one. The floor in view does not - * decide: a person looking at the ground floor who asks for a roof means the house's. + * The storey a roof covers: the one named when it has a footprint, else the highest storey of its + * building with one; none named, the top storey with one of the building in view. A rectangle of + * its own needs no footprint: the storey named, else the top one that holds something. The floor + * in view does not decide: a person looking at the ground floor who asks for a roof means the + * house's. A roof level named stands for the storey under it. */ function coveredStorey( nodes: SceneNodes, @@ -75,187 +83,62 @@ function coveredStorey( ) const named = levelId ?? level const requested = named ? requireLevel(nodes, named) : null - if (requested && (rectangle || hasFootprint(nodes, requested.id))) return { storey: requested } + if ( + requested && + isStorey(nodes, requested) && + (rectangle || footprintPolygons(nodes, requested.id).length > 0) + ) + return { storey: requested } const viewed = context.activeLevelId ? nodes[context.activeLevelId] : undefined - const candidates = requested - ? buildingLevels(nodes, requested) - : viewed?.type === 'level' - ? buildingLevels(nodes, viewed) - : levelsOf(nodes) + const candidates = ( + requested + ? buildingLevels(nodes, requested) + : viewed?.type === 'level' + ? buildingLevels(nodes, viewed) + : levelsOf(nodes) + ).filter((candidate) => isStorey(nodes, candidate)) if (rectangle) { - const top = candidates.filter((candidate) => levelRole(nodes, candidate).role === 'occupied') - if (!top.length) refuse('no_levels', 'The scene has no storey to roof yet.') - return { storey: top.at(-1)! } + const top = + candidates.filter((candidate) => candidate.children.length > 0).at(-1) ?? candidates.at(-1) + if (!top) refuse('no_levels', 'The scene has no storey to roof yet.') + return { storey: top } } - const storey = candidates.filter((candidate) => hasFootprint(nodes, candidate.id)).at(-1) + const storey = candidates + .filter((candidate) => footprintPolygons(nodes, candidate.id).length > 0) + .at(-1) if (!storey) refuse( 'no_rooms', - `No rooms or closed walls on ${requested ? `${requested.id} or any other level of its building` : 'any level'}: create rooms first, or pass width and depth to roof a rectangle of your own.`, + `No rooms on ${requested ? `${requested.id} or any other level of its building` : 'any level'}: create rooms first, or pass width and depth to roof a rectangle of your own.`, requested ? { levelId: requested.id } : {}, ) return { storey, - ...(requested && { - note: `Level ${requested.id} has no rooms or closed walls; the roof covers ${storey.name ?? storey.id} (${storey.id}), the highest level with rooms.`, - }), + ...(requested && + requested.id !== storey.id && { + note: `Level ${requested.id} has no rooms; the roof covers ${storey.name ?? storey.id} (${storey.id}), the highest level with rooms.`, + }), } } -/** - * The storey's outline from its walls, every gap bridged as L5 finds them: a roof covers the - * building, not only the rooms closed so far (the 290 traced from its PNG, its doors and windows - * still gaps, got a roof over a few rooms only). - */ -function wallOutline(nodes: SceneNodes, storeyId: string) { - const walls = Object.values(nodes).filter( - (node): node is WallNode => node.type === 'wall' && node.parentId === storeyId, - ) - if (walls.length < 3) return [] - const bridges = gapBridges(walls).map( - (gap, index) => - ({ - ...walls[0], - id: `wall_roof_gap_${index}`, - start: gap.start, - end: gap.end, - thickness: gap.thickness, - curveOffset: 0, - }) as WallNode, - ) - // Ends a few centimetres apart are one corner: a vectorised plan's near-misses. - const closed = [...walls, ...bridges] - const outlines = exteriorWallOutlines(closed, NEAR_MISS) - if (!outlines.length) return [] - // Out to the walls' outer faces, as a floor plate reaches: half the outline walls' thickness. - const thickness = new Map(closed.map((wall) => [wall.id, wall.thickness ?? 0.2])) - const onOutline = exteriorWallLoops(closed, NEAR_MISS) - .flat() - .map((face) => thickness.get(face.wallId) ?? 0.2) - .sort((a, b) => a - b) - const half = (onOutline[Math.floor(onOutline.length / 2)] ?? 0.2) / 2 - return outlines.map((polygon) => grown(polygon, half)) -} - -/** - * How far a mitre may reach, in multiples of the distance moved: past it (a corner under about - * 29°, or an edge folding back on itself) the corner is bevelled, as an SVG stroke's is. - */ -const MITER_LIMIT = 4 - -/** A polygon moved out by `distance` along each edge's outward normal, corners mitred up to the limit. */ -export function grown(polygon: Pt[], distance: number): Pt[] { - const n = polygon.length - let area = 0 - for (let i = 0; i < n; i++) { - const [ax, az] = polygon[i]! - const [bx, bz] = polygon[(i + 1) % n]! - area += ax * bz - bx * az - } - const side = area > 0 ? 1 : -1 - const normal = (a: Pt, b: Pt): Pt => { - const [dx, dz] = [b[0] - a[0], b[1] - a[1]] - const length = Math.hypot(dx, dz) || 1 - return [(side * dz) / length, (-side * dx) / length] - } - return polygon.flatMap((point, i): Pt[] => { - const before = normal(polygon[(i - 1 + n) % n]!, point) - const after = normal(point, polygon[(i + 1) % n]!) - const dot = before[0] * after[0] + before[1] * after[1] - // The mitre reaches distance × √(2 / (1 + dot)): unbounded as the edges fold onto each other. - if (1 + dot < 2 / MITER_LIMIT ** 2) - return [ - [point[0] + before[0] * distance, point[1] + before[1] * distance], - [point[0] + after[0] * distance, point[1] + after[1] * distance], - ] - // Along the bisector, as far as both edges move by `distance`. - const scale = distance / (1 + dot) - return [[point[0] + (before[0] + after[0]) * scale, point[1] + (before[1] + after[1]) * scale]] - }) -} - -const NEAR_MISS = 0.1 - -/** - * Rectangles in the storey's own frame and the turn back to the world: its walls' outline, else - * its plates, else its rooms. - */ +/** Rectangles in the storey's own frame and the turn back to the world, from its footprint. */ function footprintOf(nodes: SceneNodes, storeyId: string) { - const on = (type: 'slab' | 'zone') => - Object.values(nodes).filter( - (node) => - node.type === type && - node.parentId === storeyId && - !( - node.type === 'slab' && - node.metadata && - (node.metadata as { balcony?: unknown }).balcony - ), - ) as (AnyNode & { polygon: Pt[] })[] - const plates = on('slab') - const outline = wallOutline(nodes, storeyId) - const frame = buildingFrame( - outline.length ? outline : (plates.length ? plates : on('zone')).map((node) => node.polygon), - ) + const frame = buildingFrame(footprintPolygons(nodes, storeyId)) const { rectangles, roofType } = roofRectangles(frame.polygons) return { rectangles, roofType, angle: frame.angle, toWorld: frame.toWorld } } -/** Where a roof over a storey goes: a level to reuse, a level to build above, or a refusal. */ -export type RoofHost = - | { kind: 'reuse'; level: Level } - | { kind: 'create'; above: Level; buildingId: string } - | { kind: 'refuse'; code: 'storey_above'; storeyAbove: Level } - | { kind: 'refuse'; code: 'storey_without_building' } - -/** - * The roof's host, from the levels above the covered storey in its building. One rule, a table: - * - * storey has no building refused: storey_without_building - * an occupied storey with rooms above refused: storey_above, the lowest such storey - * else, above: empty occupied storeys (floors the roof goes above the highest of them - * to be built) and support levels - * a roof level above all of those reused: the lowest such roof level - * none a new roof level above the highest one (or - * above the storey, when nothing lies above) - * - * Only a level whose role is roof, in the same building, is ever reused: a storey or a support - * level never holds the roof, and a roof level below a floor to be built is not above it. - */ -export function roofHost(nodes: SceneNodes, storey: Level): RoofHost { - if (!storey.parentId) return { kind: 'refuse', code: 'storey_without_building' } - const above = buildingLevels(nodes, storey) - .filter((other) => other.level > storey.level) - .sort((a, b) => a.level - b.level) - .map((level) => ({ level, role: levelRole(nodes, level).role })) - const built = above.find(({ level, role }) => role === 'occupied' && level.children.length > 0) - if (built) return { kind: 'refuse', code: 'storey_above', storeyAbove: built.level } - const top = above.filter(({ role }) => role !== 'roof').at(-1)?.level ?? storey - const roof = above.find(({ level, role }) => role === 'roof' && level.level > top.level) - return roof - ? { kind: 'reuse', level: roof.level } - : { kind: 'create', above: top, buildingId: storey.parentId } -} - /** * `create_roof`: a roof over a storey, one segment per rectangle of its footprint (or the one - * rectangle asked for), on a roof level above it. + * rectangle asked for), on the building's roof level above it, standing on the storey's walls. */ export const createRoof: AgentOperation = (nodes, input, context) => { const { width, depth } = input - // A finish the library lacks renders as the default (main's build-off fix, on the shared tool). + // A finish the library lacks would render as the default. const materialPreset = input.materialPreset === undefined ? undefined : requireMaterialRef(input.materialPreset, 'materialPreset', finishSurface('roof')) - const fasciaMaterialPreset = - input.fasciaMaterialPreset === undefined - ? undefined - : requireMaterialRef( - input.fasciaMaterialPreset, - 'fasciaMaterialPreset', - finishSurface('roof'), - ) if ((width === undefined) !== (depth === undefined)) refuse( 'rectangle_incomplete', @@ -265,22 +148,13 @@ export const createRoof: AgentOperation = (nodes, input, contex const rectangle = width !== undefined && depth !== undefined const { storey, note } = coveredStorey(nodes, input, rectangle, context) - const placement = roofHost(nodes, storey) - if (placement.kind === 'refuse') { - if (placement.code === 'storey_above') { - const above = placement.storeyAbove - refuse( - 'storey_above', - `${storey.name ?? storey.id} (${storey.id}) has a storey above it, ${above.name ?? above.id} (${above.id}): a roof caps the top storey. Pass the top storey (with width and depth when it has no rooms).`, - { levelId: storey.id, storeyAboveId: above.id }, - ) - } + const existing = roofsOver(nodes, storey.id) + if (existing.length) refuse( - 'storey_without_building', - `${storey.name ?? storey.id} (${storey.id}) is not in a building, so no roof level can stand above it. Put the level in a building first.`, - { levelId: storey.id }, + 'roof_exists', + `${storey.name ?? storey.id} (${storey.id}) already has a roof, ${existing.map((roof) => roof.id).join(', ')}: a storey takes one. Edit it, or delete_node it first if the person asked for a new one.`, + { levelId: storey.id, roofIds: existing.map((roof) => roof.id) }, ) - } let rectangles: RoofRectangle[] let chosenType: RoofType @@ -330,52 +204,61 @@ export const createRoof: AgentOperation = (nodes, input, contex position: [(r.minX + r.maxX) / 2 - centerX, 0, (r.minZ + r.maxZ) / 2 - centerZ], ...(input.pitch !== undefined && { pitch: input.pitch }), ...(input.overhang !== undefined && { overhang: input.overhang }), - ...fasciaFields({ ...input, fasciaMaterialPreset }), + ...fasciaFields(input), }) }) - const changes: Required> = { create: [] } - const roofLevel = - placement.kind === 'reuse' - ? placement.level - : LevelNode.parse({ - name: 'Roof', - parentId: placement.buildingId, - level: placement.above.level + 1, - height: Math.max( - wallHeight + Math.max(...segments.map((segment) => getActiveRoofHeight(segment))), - 0.2, - ), - metadata: { role: 'roof', label: 'Roof', referenceLevelId: storey.id }, - }) - const roofLevelId = roofLevel.id - const createdRoofLevelId = placement.kind === 'create' ? roofLevelId : null - if (placement.kind === 'create') - changes.create.push({ node: roofLevel, parentId: placement.buildingId }) + const peak = Math.max(...segments.map((segment) => getActiveRoofHeight(segment))) + const plan = planRoofLevel(nodes, storey.id, wallHeight + peak) + if (plan.kind === 'storey') { + const above = nodes[plan.levelId] as Level + refuse( + 'storey_above', + `${storey.name ?? storey.id} (${storey.id}) has a storey above it, ${above.name ?? above.id} (${above.id}): a roof caps the top storey. Pass the top storey (with width and depth when it has no rooms).`, + { levelId: storey.id, storeyAboveId: above.id }, + ) + } + const changes: Required> = { + create: plan.create ? [{ node: plan.create, parentId: plan.create.parentId ?? undefined }] : [], + update: plan.update, + } const [roofX, roofZ] = toWorld([centerX, centerZ]) - const roof = RoofNode.parse({ + const placed = RoofNode.parse({ name: input.name ?? 'Roof', - parentId: roofLevelId, + parentId: plan.levelId, position: [roofX, 0, roofZ], rotation: angle, + support: { kind: 'walls' }, children: segments.map((segment) => segment.id), ...(materialPreset && { materialPreset }), - metadata: { referenceLevelId: storey.id, roofLevelId }, }) - changes.create.push({ node: roof, parentId: roofLevelId }) - for (const segment of segments) - changes.create.push({ node: { ...segment, parentId: roof.id }, parentId: roof.id }) + const draft = [ + { node: placed, parentId: plan.levelId }, + ...segments.map((segment) => ({ + node: { ...segment, parentId: placed.id } as AnyNode, + parentId: placed.id, + })), + ] + // The eave on the storey's wall tops, as the editor's own follow resolves it: walls taller than + // their storey lift it, and the editor makes no second write after the call. + const seated = applySceneChanges(nodes, { ...changes, create: [...changes.create, ...draft] }) + const elevation = resolveRoofElevation( + seated[placed.id as AnyNodeId] as RoofNode, + seated as Record, + ) + const roof = { ...placed, position: [roofX, elevation, roofZ] as [number, number, number] } + changes.create.push({ node: roof, parentId: plan.levelId }, ...draft.slice(1)) return { result: { roofId: roof.id, - roofLevelId, - createdRoofLevelId, - referenceLevelId: storey.id, + roofLevelId: plan.levelId, + createdRoofLevelId: plan.create?.id ?? null, + coveredLevelId: storey.id, segmentIds: segments.map((segment) => segment.id), roofType, - message: `Created a ${roofType} roof of ${segments.length} segment${segments.length === 1 ? '' : 's'} over ${storey.name ?? storey.id}.`, + message: `Created a ${roofType} roof of ${segments.length} segment${segments.length === 1 ? '' : 's'} over ${storey.name ?? storey.id}, on the roof level ${plan.levelId}.`, ...(note && { note }), }, changes, diff --git a/packages/core/src/agent-operations/index.ts b/packages/core/src/agent-operations/index.ts index 1c9d714944..e7ae7d3be6 100644 --- a/packages/core/src/agent-operations/index.ts +++ b/packages/core/src/agent-operations/index.ts @@ -37,6 +37,7 @@ export * from './apply-changes' export * from './apply-outcome' export * from './collections' export * from './corner-window' +export * from './create-roof' export * from './create-room' export * from './create-stair' export * from './delete-node' diff --git a/packages/core/src/agent-operations/scene-queries.ts b/packages/core/src/agent-operations/scene-queries.ts index 942bd465df..3d64b96cd8 100644 --- a/packages/core/src/agent-operations/scene-queries.ts +++ b/packages/core/src/agent-operations/scene-queries.ts @@ -1,4 +1,5 @@ import type { AnyNode } from '../schema' +import { isRoofLevel } from '../services/roof-level' import type { SceneNodes } from './types' export type LevelRole = 'occupied' | 'roof' | 'support' @@ -76,9 +77,8 @@ export function contentCounts(content: readonly AnyNode[]): ContentCounts { /** A level is a storey unless it is declared (or found to be) only a roof or a support. */ export function classifyLevel(level: AnyNode, counts: ContentCounts): LevelRole { - const declared = metadataString(level, 'role') - if (declared === 'roof') return 'roof' - if (declared === 'support') return 'support' + if (isRoofLevel(level)) return 'roof' + if (metadataString(level, 'role') === 'support') return 'support' const occupied = counts.walls + counts.zones + diff --git a/packages/core/src/agent-operations/verify-scene.ts b/packages/core/src/agent-operations/verify-scene.ts index f82af58ae4..9cf4d42993 100644 --- a/packages/core/src/agent-operations/verify-scene.ts +++ b/packages/core/src/agent-operations/verify-scene.ts @@ -264,6 +264,11 @@ export const verifyScene: AgentOperation = ( } }) + const buildingOf = (levelId: string) => + levelBuildingId( + nodes as Record, + nodes[levelId] as AnyNode & { type: 'level' }, + ) const issues: SceneIssue[] = [] const report = (type: string, message: string, informational = false) => issues.push({ type, message, ...(informational ? { severity: 'info' as const } : {}) }) @@ -301,11 +306,17 @@ export const verifyScene: AgentOperation = ( 'roof_level_occupied', `${levelName} is a roof support level but contains occupied-story content; move rooms, walls, stairs, slabs, ceilings, and items to an occupied story and keep the roof level for roof geometry only`, ) - const reference = level.referenceLevelId ? nodes[level.referenceLevelId] : undefined - if (reference?.type === 'level' && level.floorIndex <= reference.level) + const storeyAbove = levels.find( + (other) => + other.isOccupiedStory && + !other.isEmpty && + other.floorIndex > level.floorIndex && + buildingOf(other.levelId) === buildingOf(level.levelId), + ) + if (storeyAbove) report( - 'roof_level_below_reference', - `${levelName} roof support level should be above its reference occupied level ${reference.name ?? reference.id}`, + 'roof_level_not_on_top', + `${levelName} is a roof level below the storey ${storeyAbove.levelName} (${storeyAbove.levelId}): exploded and solo views show its roof inside the building. A building's roof level stays above its storeys: move the storey under it, or the roof onto the roof level on top.`, ) continue } diff --git a/packages/core/src/agent-tools/index.ts b/packages/core/src/agent-tools/index.ts index 580e278c38..50bf354342 100644 --- a/packages/core/src/agent-tools/index.ts +++ b/packages/core/src/agent-tools/index.ts @@ -54,6 +54,7 @@ export * from './nodes' export * from './paint' export * from './place-items' export * from './refusal' +export * from './roofs' export * from './room-structure' export * from './search-materials' export * from './stairs' diff --git a/packages/core/src/agent-tools/levels.ts b/packages/core/src/agent-tools/levels.ts index c2bbd8a9f9..7259426a90 100644 --- a/packages/core/src/agent-tools/levels.ts +++ b/packages/core/src/agent-tools/levels.ts @@ -46,7 +46,7 @@ export const addLevelTool = { name: 'add_level', title: 'Add level', description: - 'Add an empty level to a building, as the editor does: above its highest level, or below its lowest for a basement. On a scene with no building (a new or cleared scene), it starts the scene as the editor does: a site, a building and its ground level. To copy a floor with its content, use duplicate_level.', + "Add an empty level to a building, as the editor does: above its top storey, under the building's roof level, which moves up one so the roof stays on top; or below its lowest level for a basement. On a scene with no building (a new or cleared scene), it starts the scene as the editor does: a site, a building and its ground level. To copy a floor with its content, use duplicate_level.", input: { buildingId: NodeId.optional().describe( "The building. Default: the building of the floor the person is viewing, else the scene's only building.", @@ -54,7 +54,9 @@ export const addLevelTool = { position: z .enum(['above', 'below']) .optional() - .describe('above (default): over the highest level; below: under the lowest, a basement.'), + .describe( + 'above (default): over the top storey, under the roof level; below: under the lowest, a basement.', + ), name: z .string() .min(1) diff --git a/packages/core/src/agent-tools/roofs.ts b/packages/core/src/agent-tools/roofs.ts index 615ec0f784..202303211b 100644 --- a/packages/core/src/agent-tools/roofs.ts +++ b/packages/core/src/agent-tools/roofs.ts @@ -1,33 +1,21 @@ import { z } from 'zod' +import { RoofType } from '../schema/nodes/roof-types' import { measurement } from './measurement' import { NodeId } from './node-id' -// The schema's RoofType, restated: the schema module pulls in packages a contract may not. -const ROOF_TYPES = [ - 'hip', - 'gable', - 'shed', - 'gambrel', - 'dutch', - 'mansard', - 'flat', - 'conical', -] as const - export const createRoofTool = { name: 'create_roof', title: 'Create roof', description: - "Cap a storey with a roof over its footprint (its floor plates, else its rooms), swept in the building's own axes into rectangles, one roof segment each. Without a roofType: a gable for one rectangle, hips for a few wings, flat for a large block. The roof goes on a roof level of its own above the storey: the level directly above when it is a roof level, else a new one above any storey that is still empty. Call it once the storeys and their exterior walls stand. Refused with a code: a storey with another storey above it (a roof caps the top one), and no rooms on any level of the building (pass width and depth to roof a rectangle of your own).", + "Cap a storey with a roof over its footprint (its floor plates, else its rooms), swept in the building's own axes into rectangles, one roof segment each. Without a roofType: a gable for one rectangle, hips for a few wings, flat for a large block. The roof goes on the building's roof level, the level directly above the top storey (made, named Roof, when there is none), and stands on the storey's wall tops, so exploded and solo level views lift it off the plan. Call it once the storeys and their exterior walls stand. Refused with a code: storey_above (another storey stands above: a roof caps the top one), roof_exists (the storey already has a roof: edit it, or delete_node it first when the person asked for a new one), no_rooms (no rooms on any level of the building: pass width and depth to roof a rectangle of your own).", input: { levelId: NodeId.optional().describe( 'The storey to cover, by an id list_levels returned. Default: the top storey with rooms. A level without rooms stands for the highest storey of its building that has some.', ), level: NodeId.optional().describe('Same as levelId.'), - roofType: z - .enum(ROOF_TYPES) - .optional() - .describe('The shape of every segment. Default: chosen from the footprint.'), + roofType: RoofType.optional().describe( + 'The shape of every segment. Default: chosen from the footprint.', + ), pitch: measurement('angle', 'deg', { min: 0, max: 85, diff --git a/packages/core/src/contracts/reference-inventory.ts b/packages/core/src/contracts/reference-inventory.ts index b8161c5efa..d944f43429 100644 --- a/packages/core/src/contracts/reference-inventory.ts +++ b/packages/core/src/contracts/reference-inventory.ts @@ -866,13 +866,6 @@ export const METADATA_REFERENCES: readonly ExistingReference[] = [ ...policy('label', 'internal', 'drop', 'strip'), note: 'Offset group label.', }), - ...(['referenceLevelId', 'roofLevelId'] as const).map((key) => - meta(key, { - ...policy('node', 'membership', 'drop', 'strip'), - targetKinds: ['level'], - note: 'Written by the MCP roof tools on roof levels and roofs.', - }), - ), meta('floorPlanUrl', { ...policy('asset', 'content', 'freeze', 'keep') }), ...( [ @@ -972,7 +965,7 @@ export const METADATA_NON_REFERENCES: readonly { path: string; reason: string }[ 'ownerPose.runLength', 'ownerPose.width', ]), - ...described('Display or authoring label written by the MCP tools.', ['label', 'name']), + ...described('Display or authoring label written by the MCP tools.', ['name']), ...described('IFC attribute copy: a value or IFC label, not an id.', [ 'elevation', 'height', diff --git a/packages/core/src/schema/nodes/roof-segment.ts b/packages/core/src/schema/nodes/roof-segment.ts index 4b77ff33bc..946e301e45 100644 --- a/packages/core/src/schema/nodes/roof-segment.ts +++ b/packages/core/src/schema/nodes/roof-segment.ts @@ -3,19 +3,9 @@ import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' import type { MaterialSchema as MaterialSchemaType } from '../material' import { MaterialSchema } from '../material' +import { RoofType } from './roof-types' -export const RoofType = z.enum([ - 'hip', - 'gable', - 'shed', - 'gambrel', - 'dutch', - 'mansard', - 'flat', - 'conical', -]) - -export type RoofType = z.infer +export { RoofType } from './roof-types' export const MIN_ROOF_SEGMENT_TRIM_SPAN = 0.1 const DEFAULT_ROOF_SEGMENT_WIDTH = 8 diff --git a/packages/core/src/schema/nodes/roof-types.ts b/packages/core/src/schema/nodes/roof-types.ts new file mode 100644 index 0000000000..df34fe4826 --- /dev/null +++ b/packages/core/src/schema/nodes/roof-types.ts @@ -0,0 +1,16 @@ +import { z } from 'zod' + +// Roof shapes, in a file with no other dependency: agent tool contracts validate against them +// without loading the node schemas. +export const RoofType = z.enum([ + 'hip', + 'gable', + 'shed', + 'gambrel', + 'dutch', + 'mansard', + 'flat', + 'conical', +]) + +export type RoofType = z.infer diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index 8a759a3960..3a4bead15d 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -91,6 +91,13 @@ export { type RiserLine, type RiserMarker, } from './riser-diagram' +export { + isRoofLevel, + planRoofLevel, + type RoofLevelPlan, + roofsOver, + storeyInsertIndex, +} from './roof-level' export { SHELF_BOARD_INSET, shelfBoardDimensions, shelfRowBoardDimensions } from './shelf-board' export { DEFAULT_ANGLE_STEP, diff --git a/packages/core/src/services/roof-level.test.ts b/packages/core/src/services/roof-level.test.ts new file mode 100644 index 0000000000..ecf0ab7cb4 --- /dev/null +++ b/packages/core/src/services/roof-level.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, test } from 'bun:test' +import { type AnyNode, BuildingNode, LevelNode, RoofNode, WallNode, ZoneNode } from '../schema' +import { isRoofLevel, planRoofLevel, roofsOver, storeyInsertIndex } from './roof-level' + +// One rule for every roof writer (the owner, 12 October: a roof made on the ground floor's level +// blocks the exploded view): a roof lives on the level directly above the storey whose walls carry +// it, and over the top storey that is the building's roof level. + +type Content = 'walls' | 'roof' | 'empty' + +function house(...stack: { id: string; content: Content; metadata?: Record }[]) { + const nodes: Record = {} + const building = BuildingNode.parse({ id: 'building_h', children: stack.map(({ id }) => id) }) + nodes[building.id] = building + stack.forEach(({ id, content, metadata }, index) => { + const children: AnyNode[] = + content === 'walls' + ? [ + WallNode.parse({ id: `wall_${id}`, parentId: id, start: [0, 0], end: [6, 0] }), + ZoneNode.parse({ + id: `zone_${id}`, + parentId: id, + name: 'Room', + polygon: [ + [0, 0], + [6, 0], + [6, 4], + [0, 4], + ], + }), + ] + : content === 'roof' + ? [RoofNode.parse({ id: `roof_${id}`, parentId: id })] + : [] + nodes[id] = LevelNode.parse({ + id, + parentId: building.id, + level: index, + height: 2.5, + children: children.map((child) => child.id), + ...(metadata ? { metadata } : {}), + }) + for (const child of children) nodes[child.id] = child + }) + return nodes +} + +describe('planRoofLevel', () => { + test('over a bungalow, a new level named Roof on top, declared for the roof', () => { + const plan = planRoofLevel(house({ id: 'level_g', content: 'walls' }), 'level_g', 3) + expect(plan.kind).toBe('roof') + if (plan.kind !== 'roof') return + expect(plan.create).toMatchObject({ name: 'Roof', level: 1, height: 3, parentId: 'building_h' }) + expect(isRoofLevel(plan.create!)).toBe(true) + expect(plan.levelId).toBe(plan.create!.id) + }) + + test('over the top of two storeys, the roof level goes above the upper one', () => { + const nodes = house({ id: 'level_g', content: 'walls' }, { id: 'level_1', content: 'walls' }) + expect(planRoofLevel(nodes, 'level_1', 2)).toMatchObject({ + kind: 'roof', + create: { level: 2 }, + }) + }) + + test('over a storey with a storey above it, that storey: a lower roof covers part of it', () => { + const nodes = house({ id: 'level_g', content: 'walls' }, { id: 'level_1', content: 'walls' }) + expect(planRoofLevel(nodes, 'level_g', 2)).toEqual({ kind: 'storey', levelId: 'level_1' }) + }) + + test('the declared roof level above is reused as it is', () => { + const nodes = house( + { id: 'level_g', content: 'walls' }, + { id: 'level_r', content: 'roof', metadata: { role: 'roof' } }, + ) + expect(planRoofLevel(nodes, 'level_g', 2)).toEqual({ + kind: 'roof', + levelId: 'level_r', + create: null, + update: [], + }) + }) + + test('a level that only holds roofs, never declared, is reused and declared', () => { + const nodes = house({ id: 'level_g', content: 'walls' }, { id: 'level_r', content: 'roof' }) + const plan = planRoofLevel(nodes, 'level_g', 2) + expect(plan).toMatchObject({ kind: 'roof', levelId: 'level_r', create: null }) + if (plan.kind !== 'roof') return + expect(plan.update).toEqual([ + { id: 'level_r', data: { metadata: { role: 'roof' }, name: 'Roof' } }, + ]) + }) + + test('an empty level above becomes the roof level, keeping a name a person gave it', () => { + const nodes = house({ id: 'level_g', content: 'walls' }, { id: 'level_e', content: 'empty' }) + nodes.level_e = { ...nodes.level_e!, name: 'Attic' } as AnyNode + const plan = planRoofLevel(nodes, 'level_g', 2) + expect(plan).toMatchObject({ kind: 'roof', levelId: 'level_e', create: null }) + if (plan.kind !== 'roof') return + expect(plan.update[0]?.data).toEqual({ metadata: { role: 'roof' }, name: 'Attic' }) + }) +}) + +describe('roofsOver', () => { + test('the roofs on the roof level directly above, and any mixed into the storey', () => { + const nodes = house({ id: 'level_g', content: 'walls' }, { id: 'level_r', content: 'roof' }) + expect(roofsOver(nodes, 'level_g').map((roof) => roof.id)).toEqual(['roof_level_r']) + const mixed = RoofNode.parse({ id: 'roof_low', parentId: 'level_g' }) + const level = nodes.level_g as LevelNode + nodes.level_g = { ...level, children: [...level.children, mixed.id] } + nodes[mixed.id] = mixed + expect( + roofsOver(nodes, 'level_g') + .map((roof) => roof.id) + .sort(), + ).toEqual(['roof_level_r', 'roof_low']) + }) + + test('none on a storey with a storey above it', () => { + const nodes = house({ id: 'level_g', content: 'walls' }, { id: 'level_1', content: 'walls' }) + expect(roofsOver(nodes, 'level_g')).toEqual([]) + }) +}) + +describe('storeyInsertIndex', () => { + test('a new top storey takes the roof level’s place and the roof level moves up one', () => { + const nodes = house({ id: 'level_g', content: 'walls' }, { id: 'level_r', content: 'roof' }) + expect(storeyInsertIndex(nodes, 'building_h')).toEqual({ + floorIndex: 1, + shift: [{ id: 'level_r', data: { level: 2 } }], + }) + }) + + test('with no roof level, above the highest level', () => { + const nodes = house({ id: 'level_g', content: 'walls' }, { id: 'level_e', content: 'empty' }) + expect(storeyInsertIndex(nodes, 'building_h')).toEqual({ floorIndex: 2, shift: [] }) + }) + + test('a building with no level starts at the ground floor', () => { + expect(storeyInsertIndex(house(), 'building_h')).toEqual({ floorIndex: 0, shift: [] }) + }) +}) diff --git a/packages/core/src/services/roof-level.ts b/packages/core/src/services/roof-level.ts new file mode 100644 index 0000000000..4e34ef27af --- /dev/null +++ b/packages/core/src/services/roof-level.ts @@ -0,0 +1,143 @@ +import { getDefaultLevelName, getLevelDisplayName } from '../lib/level-name' +import { type AnyNode, LevelNode, type RoofNode } from '../schema' +import { getLevelAbove } from './storey' + +/** + * Roofs and levels, one rule for every path that writes a roof (the editor's roof tool, the + * agents' create_roof, plugins): a roof lives on the level directly above the storey whose walls + * carry it. Over a building's top storey that is the building's roof level, kept above every + * storey, so exploded and solo level views lift the roof off the plan. + */ + +type Nodes = Readonly> + +/** What a storey holds and a roof level must not. */ +const STOREY_CONTENT: ReadonlySet = new Set([ + 'wall', + 'zone', + 'slab', + 'ceiling', + 'item', + 'stair', +]) + +/** A level declared for its building's roofs (`metadata.role: 'roof'`). */ +export function isRoofLevel(node: AnyNode | undefined): boolean { + return node?.type === 'level' && node.metadata?.role === 'roof' +} + +const childrenOf = (nodes: Nodes, level: LevelNode) => + level.children.map((id) => nodes[id]).filter((child): child is AnyNode => child !== undefined) + +const roofsOn = (nodes: Nodes, level: LevelNode) => + childrenOf(nodes, level).filter((child): child is RoofNode => child.type === 'roof') + +/** + * A level that is its building's roof level: declared, or holding roofs and nothing a storey holds + * (a roof level written before levels declared it). + */ +function servesAsRoofLevel(nodes: Nodes, level: LevelNode): boolean { + if (isRoofLevel(level)) return true + const children = childrenOf(nodes, level) + return ( + children.some((child) => child.type === 'roof') && + !children.some((child) => STOREY_CONTENT.has(child.type)) + ) +} + +const levelAbove = (nodes: Nodes, levelId: string) => + getLevelAbove(levelId, nodes as Parameters[1]) + +export type RoofLevelPlan = + /** A storey stands directly above: it takes a roof over part of the storey below. */ + | { kind: 'storey'; levelId: string } + /** The building's roof level, `create`d first when it has none, `update`d when undeclared. */ + | { + kind: 'roof' + levelId: string + create: LevelNode | null + update: { id: string; data: Partial }[] + } + +/** + * Where a roof carried by `storeyId`'s walls lives. Directly above the storey stands either a + * storey (the roof covers part of the storey below it: a porch, a lower wing) or the building's + * roof level: the level there when it serves as one, or is empty, declared if it was not; else a + * new "Roof" level `roofHeight` tall (the roof's rise over its eaves). Pure: the caller writes the + * plan in the same step as the roof. + */ +export function planRoofLevel(nodes: Nodes, storeyId: string, roofHeight: number): RoofLevelPlan { + const storey = nodes[storeyId] + if (storey?.type !== 'level') throw Error(`${storeyId} is not a level`) + const above = levelAbove(nodes, storeyId) + if (above && !servesAsRoofLevel(nodes, above) && above.children.length > 0) + return { kind: 'storey', levelId: above.id } + if (above) + return { + kind: 'roof', + levelId: above.id, + create: null, + update: isRoofLevel(above) + ? [] + : [ + { + id: above.id, + data: { + metadata: { ...above.metadata, role: 'roof' }, + // A name a person gave stays; an unnamed level would read as a floor. + name: + getLevelDisplayName(above) === getDefaultLevelName(above.level) + ? 'Roof' + : above.name, + }, + }, + ], + } + const level = LevelNode.parse({ + name: 'Roof', + parentId: storey.parentId, + level: storey.level + 1, + height: Math.max(roofHeight, 0.2), + children: [], + metadata: { role: 'roof' }, + }) + return { kind: 'roof', levelId: level.id, create: level, update: [] } +} + +/** The roofs over a storey: on the storey itself, and on the roof level directly above it. */ +export function roofsOver(nodes: Nodes, storeyId: string): RoofNode[] { + const storey = nodes[storeyId] + if (storey?.type !== 'level') return [] + const above = levelAbove(nodes, storeyId) + return [storey, ...(above && servesAsRoofLevel(nodes, above) ? [above] : [])].flatMap((level) => + roofsOn(nodes, level), + ) +} + +/** + * Where a new top storey of `buildingId` goes: on top of its storeys, under the roof levels at the + * top of its stack, which each move up one (`shift`), so the roof stays above every storey. + */ +export function storeyInsertIndex( + nodes: Nodes, + buildingId: string, +): { floorIndex: number; shift: { id: string; data: Pick }[] } { + const building = nodes[buildingId] + const levels = Object.values(nodes) + .filter( + (node): node is LevelNode => + node.type === 'level' && + (node.parentId === buildingId || + (building?.type === 'building' && building.children.includes(node.id))), + ) + .sort((a, b) => a.level - b.level) + let top = levels.length + while (top > 0 && servesAsRoofLevel(nodes, levels[top - 1]!)) top-- + const roofLevels = levels.slice(top) + if (!roofLevels.length) + return { floorIndex: levels.length ? levels.at(-1)!.level + 1 : 0, shift: [] } + return { + floorIndex: roofLevels[0]!.level, + shift: roofLevels.map((level) => ({ id: level.id, data: { level: level.level + 1 } })), + } +} diff --git a/packages/editor/src/components/ui/command-palette/editor-commands.tsx b/packages/editor/src/components/ui/command-palette/editor-commands.tsx index 36a306f3cb..4c5d7c83b0 100644 --- a/packages/editor/src/components/ui/command-palette/editor-commands.tsx +++ b/packages/editor/src/components/ui/command-palette/editor-commands.tsx @@ -1,7 +1,6 @@ 'use client' -import type { AnyNodeId } from '@pascal-app/core' -import { DEFAULT_LEVEL_HEIGHT, LevelNode, type UnitNode, useScene } from '@pascal-app/core' +import { type AnyNodeId, type LevelNode, type UnitNode, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { AppWindow, @@ -37,6 +36,7 @@ import { Video, } from 'lucide-react' import { useEffect } from 'react' +import { addStoreyOnTop } from '../../../lib/add-storey' import { getHistoryCommandState, runRedo, runUndo } from '../../../lib/history' import { deleteLevelWithFallbackSelection } from '../../../lib/level-selection' import { getSavedSceneDocument } from '../../../lib/scene' @@ -203,16 +203,7 @@ export function EditorCommands() { const { nodes } = useScene.getState() const building = Object.values(nodes).find((n) => n.type === 'building') if (!building) return - const levelCount = building.children.filter( - (childId) => nodes[childId as keyof typeof nodes]?.type === 'level', - ).length - const newLevel = LevelNode.parse({ - level: levelCount, - height: DEFAULT_LEVEL_HEIGHT, - children: [], - parentId: building.id, - }) - useScene.getState().createNode(newLevel, building.id) + const newLevel = addStoreyOnTop(building.id) useViewer.getState().setSelection({ levelId: newLevel.id }) }), }, diff --git a/packages/editor/src/components/ui/floating-level-selector.tsx b/packages/editor/src/components/ui/floating-level-selector.tsx index cc2afec102..66397f1fb2 100644 --- a/packages/editor/src/components/ui/floating-level-selector.tsx +++ b/packages/editor/src/components/ui/floating-level-selector.tsx @@ -52,6 +52,7 @@ import { type LevelDuplicatePreset, } from '@pascal-app/core/building' import { getDefaultLevelName, getLevelDisplayName } from '@pascal-app/core' +import { addStoreyOnTop } from '../../lib/add-storey' import { deleteLevelWithFallbackSelection } from '../../lib/level-selection' import { unitMemberLevels, leaveUnitFocus } from '../../lib/units' import { useLinearDisplay } from '../../lib/use-linear-display' @@ -502,16 +503,9 @@ export function FloatingLevelSelector() { const handleAddAbove = useCallback(() => { if (!resolvedBuildingId) return - const maxLevel = levels.length > 0 ? Math.max(...levels.map((l) => l.level)) : -1 - const newLevel = LevelNode.parse({ - level: maxLevel + 1, - height: DEFAULT_LEVEL_HEIGHT, - children: [], - parentId: resolvedBuildingId, - }) - createNode(newLevel, resolvedBuildingId) + const newLevel = addStoreyOnTop(resolvedBuildingId) setSelection({ buildingId: resolvedBuildingId, levelId: newLevel.id }) - }, [resolvedBuildingId, levels, createNode, setSelection]) + }, [resolvedBuildingId, setSelection]) const handleAddBelow = useCallback(() => { if (!resolvedBuildingId) return diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx index 06f50f6a17..f0a20465dd 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx @@ -70,6 +70,7 @@ import { Input } from './../../../../../components/ui/primitives/input' import { Switch } from './../../../../../components/ui/primitives/switch' import { cn } from './../../../../../lib/utils' import { refusedObjectsNotice } from './../../../../../components/editor/group-actions' +import { addStoreyOnTop } from './../../../../../lib/add-storey' import { deleteLevelWithFallbackSelection } from './../../../../../lib/level-selection' import { bringBuildArtifacts } from './../../../../../lib/scene-clipboard' import useEditor, { selectDefaultBuildingAndLevel } from './../../../../../store/use-editor' @@ -416,9 +417,13 @@ function FloorSettingsSection() { if (!building) return null - const addLevel = (level: number) => { + const addFloor = () => { + const newLevel = addStoreyOnTop(building.id) + setSelection({ buildingId: building.id, levelId: newLevel.id }) + } + const addBasement = () => { const newLevel = LevelNode.parse({ - level, + level: Math.min(0, ...levels.map((level) => level.level)) - 1, height: DEFAULT_LEVEL_HEIGHT, children: [], parentId: building.id, @@ -427,10 +432,6 @@ function FloorSettingsSection() { setSelection({ buildingId: building.id, levelId: newLevel.id }) } - const addFloor = () => - addLevel(levels.length === 0 ? 0 : Math.max(0, ...levels.map((level) => level.level)) + 1) - const addBasement = () => addLevel(Math.min(0, ...levels.map((level) => level.level)) - 1) - return (
diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx index ed07d6273c..536db443e8 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx @@ -1,8 +1,9 @@ -import { type BuildingNode, DEFAULT_LEVEL_HEIGHT, LevelNode, useScene } from '@pascal-app/core' +import { type BuildingNode, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { Building2, Plus } from 'lucide-react' import { memo, useState } from 'react' import { useShallow } from 'zustand/react/shallow' +import { addStoreyOnTop } from './../../../../../lib/add-storey' import { Tooltip, TooltipContent, @@ -23,7 +24,6 @@ export const BuildingTreeNode = memo(function BuildingTreeNode({ isLast, }: BuildingTreeNodeProps) { const [expanded, setExpanded] = useState(true) - const createNode = useScene((state) => state.createNode) const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false) const name = useScene((s) => s.nodes[nodeId]?.name) // Levels and elevators first, units after, so the units read as one group. @@ -46,15 +46,7 @@ export const BuildingTreeNode = memo(function BuildingTreeNode({ const handleAddLevel = (e: React.MouseEvent) => { e.stopPropagation() - const nodes = useScene.getState().nodes - const levelCount = children.filter((childId) => nodes[childId]?.type === 'level').length - const newLevel = LevelNode.parse({ - level: levelCount, - height: DEFAULT_LEVEL_HEIGHT, - children: [], - parentId: nodeId, - }) - createNode(newLevel, nodeId) + addStoreyOnTop(nodeId) } return ( diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx index bfcf92f41a..f995874b02 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx @@ -2,10 +2,9 @@ import { type AnyNode, type AnyNodeId, type BuildingNode, - DEFAULT_LEVEL_HEIGHT, emitter, type GuideNode, - LevelNode, + type LevelNode, type ScanNode, type SiteNode, useScene, @@ -55,6 +54,7 @@ import { type LevelDuplicatePreset, } from '@pascal-app/core/building' import { getDefaultLevelName, getLevelDisplayName } from '@pascal-app/core' +import { addStoreyOnTop } from './../../../../../lib/add-storey' import { deleteLevelWithFallbackSelection } from './../../../../../lib/level-selection' import { formatAreaLabel, @@ -1014,7 +1014,6 @@ const LevelsSection = memo(function LevelsSection({ onUploadAsset?: (projectId: string, levelId: string, file: File, type: 'scan' | 'guide') => void onDeleteAsset?: (projectId: string, url: string) => void } = {}) { - const createNode = useScene((state) => state.createNode) const updateNode = useScene((state) => state.updateNode) const selectedBuildingId = useViewer((state) => state.selection.buildingId) const selectedLevelId = useViewer((state) => state.selection.levelId) @@ -1037,13 +1036,7 @@ const LevelsSection = memo(function LevelsSection({ if (!building) return null const handleAddLevel = () => { - const newLevel = LevelNode.parse({ - level: levels.length, - height: DEFAULT_LEVEL_HEIGHT, - children: [], - parentId: building.id, - }) - createNode(newLevel, building.id) + const newLevel = addStoreyOnTop(building.id) setSelection({ buildingId: building.id, levelId: newLevel.id }) } diff --git a/packages/editor/src/lib/add-storey.test.ts b/packages/editor/src/lib/add-storey.test.ts new file mode 100644 index 0000000000..f78e09fad2 --- /dev/null +++ b/packages/editor/src/lib/add-storey.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeId, + BuildingNode, + LevelNode, + RoofNode, + useScene, +} from '@pascal-app/core' +import { addStoreyOnTop } from './add-storey' + +// The editor's "add a floor" on a roofed house: the new storey goes under the roof, not over it. +const BUILDING = 'building_add-storey' as BuildingNode['id'] + +function seed() { + const ground = LevelNode.parse({ id: 'level_ground', parentId: BUILDING, level: 0 }) + const roofLevel = LevelNode.parse({ + id: 'level_roof', + parentId: BUILDING, + level: 1, + name: 'Roof', + children: ['roof_main'], + metadata: { role: 'roof' }, + }) + const roof = RoofNode.parse({ id: 'roof_main', parentId: roofLevel.id }) + const building = BuildingNode.parse({ id: BUILDING, children: [ground.id, roofLevel.id] }) + useScene.setState({ + nodes: Object.fromEntries( + [building, ground, roofLevel, roof].map((node) => [node.id, node as AnyNode]), + ), + rootNodeIds: [BUILDING], + collections: {}, + dirtyNodes: new Set(), + } as never) +} + +describe('addStoreyOnTop', () => { + beforeEach(() => { + useScene.temporal.getState().clear() + useScene.temporal.getState().resume() + seed() + }) + + test('puts the new storey under the roof level, which moves up one', () => { + const level = addStoreyOnTop(BUILDING) + const nodes = useScene.getState().nodes + expect(nodes[level.id as AnyNodeId]).toMatchObject({ type: 'level', level: 1 }) + expect(nodes['level_roof' as AnyNodeId]).toMatchObject({ level: 2 }) + }) + + test('is one undo step', () => { + const level = addStoreyOnTop(BUILDING) + useScene.temporal.getState().undo() + const nodes = useScene.getState().nodes + expect(nodes[level.id as AnyNodeId]).toBeUndefined() + expect(nodes['level_roof' as AnyNodeId]).toMatchObject({ level: 1 }) + }) +}) diff --git a/packages/editor/src/lib/add-storey.ts b/packages/editor/src/lib/add-storey.ts new file mode 100644 index 0000000000..1e1d050225 --- /dev/null +++ b/packages/editor/src/lib/add-storey.ts @@ -0,0 +1,29 @@ +import { + type AnyNode, + type AnyNodeId, + type BuildingNode, + DEFAULT_LEVEL_HEIGHT, + LevelNode, + storeyInsertIndex, + useScene, +} from '@pascal-app/core' + +/** + * The editor's "add a floor": an empty storey on top of the building's storeys, under its roof + * level, which moves up one in the same undo step, so the roof stays on top (add_level's rule). + */ +export function addStoreyOnTop(buildingId: BuildingNode['id']): LevelNode { + const scene = useScene.getState() + const { floorIndex, shift } = storeyInsertIndex(scene.nodes, buildingId) + const level = LevelNode.parse({ + level: floorIndex, + height: DEFAULT_LEVEL_HEIGHT, + children: [], + parentId: buildingId, + }) + scene.applyNodeChanges({ + create: [{ node: level, parentId: buildingId }], + update: shift.map(({ id, data }) => ({ id: id as AnyNodeId, data: data as Partial })), + }) + return level +} diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 1376fa480a..62d92751c0 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -360,15 +360,15 @@ an optional `height` and uses the same default. | `create_stair` | Create a straight 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. `(x, z)` is the back-centre of the bottom step, `rotation` in degrees (0 climbs toward +Z). | `{ x, z, levelId?, toLevelId?, rotation?, width?, length?, height?, steps? }` | `{ stairId, segmentId, upperLevelId, createdUpperLevel, stepCount, slabHoleCut, openingIds? }`; refusals `roof_level`, `not_above`, `level_not_found`, `slab_not_found` / `ceiling_not_found`, `slab_not_on_level` / `ceiling_not_on_level` | | `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` | 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_roof` | Cap the top storey with a roof over its footprint (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, directly above the storey (made, named Roof, when there is none), and stands on the storey's wall tops; the hand roof tool follows the same rule. | `{ levelId?, roofType?, pitch?, overhang?, wallHeight?, width?, depth?, center?, materialPreset?, fasciaHeight?, fasciaMaterialPreset?, name? }` | `{ roofId, roofLevelId, createdRoofLevelId, coveredLevelId, segmentIds, roofType, achieved }`; refusals `storey_above`, `roof_exists`, `no_rooms`, `rectangle_incomplete`, `unknown_material` | | `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`) | | `add_window` | Add a window as `add_door` places a door, centred vertically on the wall unless `sillHeight` (its bottom edge above the floor) is given; `windowType`, `columns` / `rows`, `style` (a Fixed window's panes; refused on another type, `style_needs_fixed_window`) and the outline match a reference. | `{ wallId, t?, width?, height?, sillHeight?, windowType?, columns?, rows?, style?, openingShape?, code?, nodeId?, force? }` | `{ windowId, wallId, localX, t, clamped, sillHeight, achieved }`; the same refusals as `add_door` | | `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 or at a thin post, at any angle from 10° to 170° (a square corner, a bay's 135°, an acute one). `corner` is the point the walls end at, `[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, the default) or `'post'`; `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, None or Post, Unwrap). | `{ corner: [x, z], levelId?, 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`), `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, 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 outer edges, 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 }`; 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 }`. | `{ patches: Patch[] }` | `{ applied: number, notes?: string[] }` | -| `add_level` | Add an empty level to a building, as the editor does: above the highest, or below the lowest for a basement. Omit `buildingId` for the scene's only building. | `{ buildingId?, position?, name?, height? }` | `{ levelId, buildingId, floorIndex, height }`; refusals `building_required`, `building_not_found`, `no_building` | +| `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[] }` | +| `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` | | `place_items` | Place catalog items in one call, each placed or refused on its own, at level `(x, z)`: on the level's floor, or on the host each names (`targetNodeId`): a wall (`y`, the height of its bottom, required), a ceiling, or an item standing on the floor. A floor item in a door's clearance is refused `blocks_door` (naming the door and a spot that clears every door), one its room cannot hold in any turn `too_large_for_room`. | `{ items: [{ assetId, x, z, y?, targetNodeId?, rotation? }], levelId? }` | `{ levelId, items: [{ ok, itemId?, hostId?, restingOn?, code? }] }`; per-item refusals `asset_not_found`, `outside_rooms`, `blocks_door`, `too_large_for_room`, `height_required`, `item_too_tall`, `unsupported_host`, `host_not_found`, `host_not_on_level` | diff --git a/packages/mcp/src/resources/agent-guide.ts b/packages/mcp/src/resources/agent-guide.ts index 7df479affa..db3a641c04 100644 --- a/packages/mcp/src/resources/agent-guide.ts +++ b/packages/mcp/src/resources/agent-guide.ts @@ -48,7 +48,7 @@ export const AGENT_GUIDE = [ '- For complete homes, create exterior shell, interior rooms, openings, roof, furniture, then landscaping.', '- For doors/windows, use `t` or `position` from 0 to 1 along the wall unless a tool explicitly says otherwise.', '- X/Z are floor-plan axes and Y is vertical; dimensions are meters.', - '- Use `create_roof` for roofs. A dedicated roof support level is valid and should not count as an occupied story.', + "- Use `create_roof` for roofs, once the storeys and their exterior walls stand. A building's roof goes on its roof level, the level directly above the top storey (named Roof, made when there is none), so exploded and solo level views lift it off the plan; a roof level is not an occupied story. `add_level` puts a new storey under it. A storey takes one roof: `roof_exists` names the one there.", '', '## Stairs', '', diff --git a/packages/mcp/src/tools/construction-tools.test.ts b/packages/mcp/src/tools/construction-tools.test.ts index b6ce1e482f..35204775e3 100644 --- a/packages/mcp/src/tools/construction-tools.test.ts +++ b/packages/mcp/src/tools/construction-tools.test.ts @@ -338,7 +338,7 @@ describe('construction tools', () => { expect(roofLevel?.type).toBe('level') if (roofLevel?.type === 'level') { expect(roofLevel.level).toBe(level.type === 'level' ? level.level + 1 : 1) - expect(roofLevel.metadata).toMatchObject({ role: 'roof', referenceLevelId: level.id }) + expect(roofLevel.metadata).toMatchObject({ role: 'roof' }) } expect(roof?.parentId).toBe(parsed.roofLevelId) expect(roof?.type).toBe('roof') diff --git a/packages/mcp/src/tools/construction-tools.ts b/packages/mcp/src/tools/construction-tools.ts index 652e139bb5..d9cc6b4831 100644 --- a/packages/mcp/src/tools/construction-tools.ts +++ b/packages/mcp/src/tools/construction-tools.ts @@ -1,5 +1,5 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import { createZone, generateId } from '@pascal-app/core' +import { createZone, generateId, isRoofLevel } from '@pascal-app/core' import { unknownMaterialPresetRefusal } from '@pascal-app/core/agent-operations' import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema' import { z } from 'zod' @@ -68,16 +68,6 @@ function assertNode(bridge: SceneOperations, id: string, type: AnyNode['type']): return node } -function isRoofLevel(level: AnyNode): boolean { - return ( - level.type === 'level' && - typeof level.metadata === 'object' && - level.metadata !== null && - 'role' in level.metadata && - level.metadata.role === 'roof' - ) -} - /** Room zones that appeared on the level while this tool call ran. */ function newRoomZoneIds(bridge: SceneOperations, levelId: string, before: ReadonlySet) { return Object.values(bridge.getNodes()) diff --git a/packages/mcp/src/tools/set-zone.ts b/packages/mcp/src/tools/set-zone.ts index d2e33f317c..3db1305a1d 100644 --- a/packages/mcp/src/tools/set-zone.ts +++ b/packages/mcp/src/tools/set-zone.ts @@ -1,5 +1,5 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import { newZone, walledRoomToName } from '@pascal-app/core' +import { isRoofLevel, newZone, walledRoomToName } from '@pascal-app/core' import type { AnyNodeId } from '@pascal-app/core/schema' import { z } from 'zod' import type { SceneOperations } from '../operations' @@ -42,12 +42,7 @@ export function registerSetZone(server: McpServer, bridge: SceneOperations): voi `Node ${levelId} is a ${parent.type}, expected level`, ) } - if ( - typeof parent.metadata === 'object' && - parent.metadata !== null && - 'role' in parent.metadata && - parent.metadata.role === 'roof' - ) { + if (isRoofLevel(parent)) { throwMcpError( ErrorCode.InvalidParams, `Roof support level ${levelId} is not an occupied story; create zones on an occupied level instead`, diff --git a/packages/nodes/src/roof/conical-roof.ts b/packages/nodes/src/roof/conical-roof.ts index 8a7ff8fe29..fd736c3e42 100644 --- a/packages/nodes/src/roof/conical-roof.ts +++ b/packages/nodes/src/roof/conical-roof.ts @@ -1,6 +1,7 @@ import { type AnyNode, type AnyNodeId, + getActiveRoofHeight, getLevelBelow, getWallArcData, type LevelNode, @@ -11,26 +12,30 @@ import { type SceneApi, type WallNode, } from '@pascal-app/core' +import { roofLevelTarget, writeRoofNodes } from './roof-level-target' const DEFAULT_CONICAL_ROOF_PITCH = 40 +/** + * A cone over a curved wall, picked from the wall's own level or the one above it (`armedLevelId`). + * It goes where every roof goes (`roofLevelTarget`): on the level directly above the wall's storey. + */ export function createConicalRoofSectorAboveWall( wall: WallNode, nodes: Readonly>, sceneApi: SceneApi, - targetLevelId: LevelNode['id'], + armedLevelId: LevelNode['id'], ): RoofSegmentNode['id'] | null { const arc = getWallArcData(wall) - if (!(arc && nodes[targetLevelId]?.type === 'level')) return null + if (!(arc && nodes[armedLevelId]?.type === 'level')) return null const completeNodes = nodes as Record - const sourceLevelId = resolveLevelId(wall, completeNodes) - const levelBelowId = getLevelBelow(targetLevelId, completeNodes)?.id - if (sourceLevelId !== targetLevelId && sourceLevelId !== levelBelowId) return null + const sourceLevelId = resolveLevelId(wall, completeNodes) as LevelNode['id'] + const levelBelowId = getLevelBelow(armedLevelId, completeNodes)?.id + if (sourceLevelId !== armedLevelId && sourceLevelId !== levelBelowId) return null const existingRoof = Object.values(nodes).find( (node): node is RoofNode => node.type === 'roof' && - node.parentId === targetLevelId && typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata) && @@ -54,19 +59,22 @@ export function createConicalRoofSectorAboveWall( conicalSweepAngle: arc.delta, conicalFullCircle: true, }) + const target = roofLevelTarget(nodes, sourceLevelId, getActiveRoofHeight(segment)) const roof = RoofNode.parse({ name: `Roof ${roofCount + 1}`, metadata: { conicalSourceWallId: wall.id }, support: { kind: 'walls' }, - position: [arc.center.x, resolveRoofWallTopElevation(targetLevelId, wall, nodes), arc.center.y], + position: [ + arc.center.x, + resolveRoofWallTopElevation(target.levelId, wall, target.nodes), + arc.center.y, + ], children: [segment.id], }) - const ops = [ - { node: roof, parentId: targetLevelId as AnyNodeId }, + writeRoofNodes(sceneApi, target, [ + { node: roof, parentId: target.levelId as AnyNodeId }, { node: segment, parentId: roof.id as AnyNodeId }, - ] - if (sceneApi.createMany) sceneApi.createMany(ops) - else for (const op of ops) sceneApi.upsert(op.node, op.parentId) + ]) return segment.id } diff --git a/packages/nodes/src/roof/roof-level-target.ts b/packages/nodes/src/roof/roof-level-target.ts new file mode 100644 index 0000000000..6e10b52b56 --- /dev/null +++ b/packages/nodes/src/roof/roof-level-target.ts @@ -0,0 +1,89 @@ +import { + type AnyNode, + type AnyNodeId, + getLevelElevations, + type LevelNode, + planRoofLevel, + type SceneApi, +} from '@pascal-app/core' +import { applySceneChanges } from '@pascal-app/core/agent-operations' + +/** + * Where a roof drawn by hand goes, by core's rule for every roof writer (`planRoofLevel`): on the + * level directly above the storey whose walls carry it, which over the top storey is the + * building's roof level, created or declared in the same undo step as the roof. + */ +export type RoofLevelTarget = { + levelId: LevelNode['id'] + create: { node: AnyNode; parentId?: AnyNodeId }[] + update: { id: AnyNodeId; data: Partial }[] + /** The scene with the target written, to resolve the roof's elevation against. */ + nodes: Readonly> +} + +const levelHoldsWalls = (nodes: Readonly>, levelId: string) => { + const level = nodes[levelId] + return level?.type === 'level' && level.children.some((id) => nodes[id]?.type === 'wall') +} + +/** The target of a roof carried by `storeyId`'s walls, `roofHeight` its rise over its eaves. */ +export function roofLevelTarget( + nodes: Readonly>, + storeyId: LevelNode['id'], + roofHeight: number, +): RoofLevelTarget { + const plan = planRoofLevel(nodes, storeyId, roofHeight) + const create = + plan.kind === 'roof' && plan.create + ? [{ node: plan.create, parentId: (plan.create.parentId ?? undefined) as AnyNodeId }] + : [] + const update = (plan.kind === 'roof' ? plan.update : []).map(({ id, data }) => ({ + id: id as AnyNodeId, + data: data as Partial, + })) + return { + levelId: plan.levelId as LevelNode['id'], + create, + update, + nodes: create.length || update.length ? applySceneChanges(nodes, { create, update }) : nodes, + } +} + +/** + * The target of a roof drawn on the armed level: over its walls when it holds some, else the + * level itself (an empty upper level or the roof level, already above what it covers). + */ +export function drawnRoofLevelTarget( + nodes: Readonly>, + levelId: LevelNode['id'], + roofHeight: number, +): RoofLevelTarget { + return levelHoldsWalls(nodes, levelId) + ? roofLevelTarget(nodes, levelId, roofHeight) + : { levelId, create: [], update: [], nodes } +} + +/** How far above the armed level a target sits (m), to draw the roof's ghost where it lands. */ +export function roofLevelTargetOffset(target: RoofLevelTarget, armedLevelId: string): number { + const elevations = getLevelElevations(target.nodes as Record) + return (elevations.get(target.levelId)?.baseY ?? 0) - (elevations.get(armedLevelId)?.baseY ?? 0) +} + +/** Writes a roof's nodes with its target level, in one step when the scene can. */ +export function writeRoofNodes( + sceneApi: SceneApi, + target: RoofLevelTarget, + ops: { node: AnyNode; parentId?: AnyNodeId }[], +): void { + const create = [...target.create, ...ops] + if (sceneApi.applyChanges) { + sceneApi.applyChanges({ create, update: target.update }) + return + } + for (const { id, data } of target.update) sceneApi.update(id, data) + if (sceneApi.createMany) { + sceneApi.createMany(create) + return + } + for (const op of create) sceneApi.upsert(op.node, op.parentId) +} diff --git a/packages/nodes/src/roof/tool.test.ts b/packages/nodes/src/roof/tool.test.ts index 4f076ffd03..d4bae3397f 100644 --- a/packages/nodes/src/roof/tool.test.ts +++ b/packages/nodes/src/roof/tool.test.ts @@ -35,8 +35,13 @@ function setup() { level.children = walls.map((wall) => wall.id) const nodes = Object.fromEntries([level, upper, ...walls].map((node) => [node.id, node])) const created: Array<{ node: AnyNode; parentId?: AnyNodeId }> = [] - const sceneApi = { nodes: () => nodes, createMany: (ops) => created.push(...ops) } as SceneApi - return { upper, nodes, created, sceneApi } + const updated: Array<{ id: AnyNodeId; data: Partial }> = [] + const sceneApi = { + nodes: () => nodes, + createMany: (ops) => created.push(...ops), + update: (id, data) => updated.push({ id, data }), + } as SceneApi + return { level, upper, nodes, created, updated, sceneApi } } test('room creation follows walls and retains the computed initial Y', () => { @@ -67,13 +72,25 @@ test("room creation from the walls' own level still parents the roof to the leve expect(roof.node).toMatchObject({ support: { kind: 'walls' }, position: [2, -0.5, 1.5] }) }) -test("room creation on the top floor keeps the roof on the walls' level at their top", () => { +test('room creation on the top floor puts the roof on a new Roof level, on the wall tops', () => { const { upper, nodes, created, sceneApi } = setup() delete nodes[upper.id] const level = Object.values(nodes).find((node) => node.type === 'level')! const target = resolveRoomRoofFootprint(level.id as LevelNode['id'], nodes, [2, 1])! commitRoofFootprint(sceneApi, level.id as LevelNode['id'], target, false) + const roofLevel = created.find(({ node }) => node.type === 'level')?.node as LevelNode const roof = created.find(({ node }) => node.type === 'roof')! - expect(roof.parentId).toBe(level.id) - expect(roof.node).toMatchObject({ support: { kind: 'walls' }, position: [2, 2.5, 1.5] }) + expect(roofLevel).toMatchObject({ name: 'Roof', level: 1, metadata: { role: 'roof' } }) + expect(roof.parentId).toBe(roofLevel.id) + // 2.5 m walls under a 3 m storey: the eave 0.5 m below the roof level's floor. + expect(roof.node).toMatchObject({ support: { kind: 'walls' }, position: [2, -0.5, 1.5] }) +}) + +test('a rectangle drawn on a walled storey goes on the level above its walls, declared the roof', () => { + const { level, upper, created, updated, sceneApi } = setup() + commitRoofPlacement(sceneApi, level.id, [0, 0, 0], [4, 0, 3], [], false, 'ground') + const roof = created.find(({ node }) => node.type === 'roof')! + expect(roof.parentId).toBe(upper.id) + expect(roof.node).toMatchObject({ support: { kind: 'level' }, position: [2, 0, 1.5] }) + expect(updated).toEqual([{ id: upper.id, data: { metadata: { role: 'roof' }, name: 'Roof' } }]) }) diff --git a/packages/nodes/src/roof/tool.tsx b/packages/nodes/src/roof/tool.tsx index b21780b2ca..17d48ffb27 100644 --- a/packages/nodes/src/roof/tool.tsx +++ b/packages/nodes/src/roof/tool.tsx @@ -4,9 +4,8 @@ import { type AnyNodeId, collectAlignmentAnchors, emitter, - findLevelAboveId, type GridEvent, - getLevelElevations, + getActiveRoofHeight, getWallArcData, getWallBaseElevationForNodes, getWallEffectiveHeightForNodes, @@ -63,23 +62,18 @@ import { subscribeToConicalRoofWallClicks, } from './roof-footprint' import useRoofFootprintSource from './roof-footprint-source' +import { + drawnRoofLevelTarget, + roofLevelTarget, + roofLevelTargetOffset, + writeRoofNodes, +} from './roof-level-target' import useRoofPlacementMode, { type RoofPlacementMode } from './roof-placement-mode' const DEFAULT_WALL_HEIGHT = 0.5 const DEFAULT_PITCH_DEG = 40 const GRID_OFFSET = 0.02 -function createRoofNodes( - sceneApi: SceneApi, - ops: Parameters>[0], -): void { - if (sceneApi.createMany) { - sceneApi.createMany(ops) - return - } - for (const op of ops) sceneApi.upsert(op.node, op.parentId) -} - function placementOptions(mode: RoofPlacementMode) { return { allowRoofSupport: mode !== 'ground', @@ -216,9 +210,17 @@ export const commitRoofPlacement = ( const diameter = Math.max(footprintWidth, footprintDepth) const curbHeight = typeof defaults.wallHeight === 'number' ? defaults.wallHeight : DEFAULT_WALL_HEIGHT + const cone = RoofSegmentNode.parse({ + pitch: DEFAULT_PITCH_DEG, + ...defaults, + roofType, + width: diameter, + depth: diameter, + }) + const target = drawnRoofLevelTarget(nodes, levelId, curbHeight + getActiveRoofHeight(cone)) const resolved = resolveConicalRoofPlacement({ - nodes, - levelId, + nodes: target.nodes, + levelId: target.levelId, center: [centerX, centerZ], radius: diameter / 2, curbHeight, @@ -245,8 +247,8 @@ export const commitRoofPlacement = ( children: [segment.id], }) - createRoofNodes(sceneApi, [ - { node: roof, parentId: levelId }, + writeRoofNodes(sceneApi, target, [ + { node: roof, parentId: target.levelId }, { node: segment, parentId: roof.id }, ]) triggerSFX('sfx:structure-build') @@ -344,9 +346,15 @@ export const commitRoofPlacement = ( children: [segment.id], }) + // Drawn over a storey's walls, the roof goes on the level above them. + const target = drawnRoofLevelTarget( + nodes, + levelId, + segment.wallHeight + getActiveRoofHeight(segment), + ) // Create roof first (so segment can be parented to it), then segment - createRoofNodes(sceneApi, [ - { node: roof, parentId: levelId }, + writeRoofNodes(sceneApi, target, [ + { node: roof, parentId: target.levelId }, { node: segment, parentId: roof.id }, ]) @@ -377,26 +385,25 @@ export const commitRoofFootprint = ( position: [0, 0, 0], rotation: quarterTurn ? Math.PI / 2 : 0, }) - // A roof belongs to the storey above the walls it covers, whichever level the - // tool was armed on; the top floor keeps it on the walls' own level. + // A roof belongs to the level above the walls it covers, whichever level the tool was armed + // on: over the top floor, the building's roof level. const firstWall = target.wallIds.map((id) => nodes[id]).find((node) => node?.type === 'wall') - const wallsLevelId = firstWall ? resolveLevelId(firstWall, nodes) : levelId - const parentLevelId = (findLevelAboveId(wallsLevelId, getLevelElevations(nodes)) ?? - wallsLevelId) as LevelNode['id'] + const wallsLevelId = (firstWall ? resolveLevelId(firstWall, nodes) : levelId) as LevelNode['id'] + const level = roofLevelTarget(nodes, wallsLevelId, getActiveRoofHeight(segment)) const roof = RoofNode.parse({ ...defaults, name: `Roof ${roofCount + 1}`, position: [ target.center[0], - resolveRoofFootprintElevation(parentLevelId, target, nodes), + resolveRoofFootprintElevation(level.levelId, target, level.nodes), target.center[1], ], rotation: target.rotation, support: { kind: 'walls' }, children: [segment.id], }) - createRoofNodes(sceneApi, [ - { node: roof, parentId: parentLevelId }, + writeRoofNodes(sceneApi, level, [ + { node: roof, parentId: level.levelId }, { node: segment, parentId: roof.id }, ]) triggerSFX('sfx:structure-build') @@ -734,9 +741,10 @@ export const RoofTool: React.FC = () => { const defaults = useEditor.getState().toolDefaults.roof const curbHeight = typeof defaults?.wallHeight === 'number' ? defaults.wallHeight : DEFAULT_WALL_HEIGHT + const target = drawnRoofLevelTarget(sceneApi.nodes(), currentLevelId, 0) const placement = resolveConicalRoofPlacement({ - nodes: sceneApi.nodes(), - levelId: currentLevelId, + nodes: target.nodes, + levelId: target.levelId, center: [centerX, centerZ], radius: diameter / 2, curbHeight, @@ -744,6 +752,8 @@ export const RoofTool: React.FC = () => { }) if (placement.valid) { gridY = + corner1[1] + + roofLevelTargetOffset(target, currentLevelId) + placement.position[1] + (placement.kind === 'roof' ? placement.wallHeight - curbHeight : 0) + GRID_OFFSET @@ -1040,17 +1050,28 @@ export const RoofTool: React.FC = () => { } : null + // A roof drawn over the armed level's walls lands on the level above them; its ghost does too. + const drawTarget = useMemo( + () => + currentLevelId && footprintSource === 'draw' + ? drawnRoofLevelTarget(nodes, currentLevelId, 0) + : null, + [currentLevelId, footprintSource, nodes], + ) + const drawLift = + drawTarget && currentLevelId ? roofLevelTargetOffset(drawTarget, currentLevelId) : 0 + const conicalPlacement = useMemo(() => { - if (!(currentLevelId && previewDimensions && roofType === 'conical')) return null + if (!(drawTarget && previewDimensions && roofType === 'conical')) return null return resolveConicalRoofPlacement({ - nodes, - levelId: currentLevelId, + nodes: drawTarget.nodes, + levelId: drawTarget.levelId, center: [previewDimensions.centerX, previewDimensions.centerZ], radius: Math.max(previewDimensions.length, previewDimensions.width) / 2, curbHeight: previewWallHeight, ...placementOptions(placementMode), }) - }, [currentLevelId, nodes, placementMode, previewDimensions, previewWallHeight, roofType]) + }, [drawTarget, placementMode, previewDimensions, previewWallHeight, roofType]) const conicalWallGhost = useMemo(() => { if (!(roofType === 'conical' && footprintSource === 'walls' && previewedConicalWallId)) @@ -1092,9 +1113,7 @@ export const RoofTool: React.FC = () => { const ghostBaseY = footprintSource !== 'draw' && footprintTarget && currentLevelId ? resolveRoofFootprintWorldElevation(currentLevelId, footprintTarget, nodes) - : conicalPlacement?.valid === true - ? conicalPlacement.position[1] - : levelY + : levelY + drawLift + (conicalPlacement?.valid === true ? conicalPlacement.position[1] : 0) const ghostColor = footprintSource !== 'draw' && footprintTarget && diff --git a/packages/nodes/src/wall/definition.test.ts b/packages/nodes/src/wall/definition.test.ts index bbd3bf6e49..ccc8eb3c95 100644 --- a/packages/nodes/src/wall/definition.test.ts +++ b/packages/nodes/src/wall/definition.test.ts @@ -142,14 +142,19 @@ test('curved wall roof builder creates a matching conical sector above it', () = const created: Array<{ node: AnyNode; parentId?: AnyNodeId }> = [] const sceneApi = { createMany: (ops) => created.push(...ops), + update: () => {}, nodes: () => nodes, } as SceneApi const segmentId = createConicalRoofSectorAboveWall(wall, nodes, sceneApi, level.id as AnyNodeId) - const roof = created.find((entry) => entry.node.type === 'roof')?.node + const roofLevel = created.find((entry) => entry.node.type === 'level')?.node + const roof = created.find((entry) => entry.node.type === 'roof') const segment = created.find((entry) => entry.node.type === 'roof-segment')?.node expect(wallDefinition.quickActions).toBeUndefined() - expect(roof).toMatchObject({ position: [0, 3, 0], support: { kind: 'walls' } }) + // On the top floor, the cone goes on a new Roof level, standing on the 3 m wall's top. + expect(roofLevel).toMatchObject({ name: 'Roof', level: 1, metadata: { role: 'roof' } }) + expect(roof?.parentId).toBe(roofLevel?.id) + expect(roof?.node).toMatchObject({ position: [0, 0, 0], support: { kind: 'walls' } }) expect(segment).toMatchObject({ roofType: 'conical', width: 4, @@ -161,7 +166,7 @@ test('curved wall roof builder creates a matching conical sector above it', () = expect(segmentId).toBe(segment?.id) }) -test('curved wall roof builder parents the roof to the active level', () => { +test('curved wall roof builder parents the roof to the empty level above the wall', () => { const sourceLevel = { object: 'node', id: 'level_source', @@ -193,6 +198,7 @@ test('curved wall roof builder parents the roof to the active level', () => { const created: Array<{ node: AnyNode; parentId?: AnyNodeId }> = [] const sceneApi = { createMany: (ops) => created.push(...ops), + update: () => {}, nodes: () => nodes, } as SceneApi @@ -284,6 +290,7 @@ test('curved wall roof builder follows a lower-floor wall top below the active f const created: Array<{ node: AnyNode; parentId?: AnyNodeId }> = [] const sceneApi = { createMany: (ops) => created.push(...ops), + update: () => {}, nodes: () => nodes, } as SceneApi @@ -321,6 +328,7 @@ test('curved wall roof builder rejects walls more than one level below', () => { const created: Array<{ node: AnyNode; parentId?: AnyNodeId }> = [] const sceneApi = { createMany: (ops) => created.push(...ops), + update: () => {}, nodes: () => nodes, } as SceneApi diff --git a/packages/viewer/src/systems/roof/roof-materials.test.ts b/packages/viewer/src/systems/roof/roof-materials.test.ts new file mode 100644 index 0000000000..f51b7be847 --- /dev/null +++ b/packages/viewer/src/systems/roof/roof-materials.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from 'bun:test' +import { + type AnyNode, + Assembly, + BuildingNode, + LevelNode, + RoofNode, + WallNode, +} from '@pascal-app/core' +import { levelWallCladdingRef } from './roof-materials' + +// A roof on its building's roof level stands on the storey below: its gables are clad like that +// storey's walls, not the bare drywall default. +const stucco = Assembly.parse({ + face: 'exterior', + layers: [ + { id: 'studs', role: 'structure', thickness: 0.14, core: true, material: 'wood' }, + { id: 'stucco', role: 'finish', thickness: 0.02, material: 'stucco' }, + ], +}) + +function house(roofOn: 'level_g' | 'level_r') { + const wall = WallNode.parse({ + id: 'wall_g', + parentId: 'level_g', + start: [0, 0], + end: [6, 0], + assembly: stucco, + }) + const roof = RoofNode.parse({ id: 'roof_main', parentId: roofOn }) + const ground = LevelNode.parse({ + id: 'level_g', + parentId: 'building_h', + level: 0, + height: 2.5, + children: [wall.id, ...(roofOn === 'level_g' ? [roof.id] : [])], + }) + const roofLevel = LevelNode.parse({ + id: 'level_r', + parentId: 'building_h', + level: 1, + children: roofOn === 'level_r' ? [roof.id] : [], + metadata: { role: 'roof' }, + }) + const building = BuildingNode.parse({ id: 'building_h', children: [ground.id, roofLevel.id] }) + const nodes: Record = Object.fromEntries( + [building, ground, roofLevel, wall, roof].map((node) => [node.id, node]), + ) + return { nodes, roof } +} + +describe('levelWallCladdingRef', () => { + test("a roof on the storey's own level takes its walls' cladding", () => { + const { nodes, roof } = house('level_g') + expect(levelWallCladdingRef(nodes, roof)).toBe('library:concrete-stucco') + }) + + test('a roof on the roof level takes the cladding of the storey below', () => { + const { nodes, roof } = house('level_r') + expect(levelWallCladdingRef(nodes, roof)).toBe('library:concrete-stucco') + }) +}) diff --git a/packages/viewer/src/systems/roof/roof-materials.ts b/packages/viewer/src/systems/roof/roof-materials.ts index ac347404e7..c39ac60767 100644 --- a/packages/viewer/src/systems/roof/roof-materials.ts +++ b/packages/viewer/src/systems/roof/roof-materials.ts @@ -1,11 +1,15 @@ import { type AnyNode, + type AnyNodeId, + findLevelBelowId, getEffectiveRoofSurfaceMaterial, + getLevelElevations, parseMaterialRef, type RoofNode, type RoofSegmentNode, type SceneMaterial, type SceneMaterialId, + type WallNode, wallAssemblyFinishRef, } from '@pascal-app/core' import type * as THREE from 'three' @@ -72,8 +76,10 @@ function createResolvedMaterial( * The cladding the walls under a roof declare through their ASSEMBLIES * (`wall.assembly.exterior.finish` → catalog ref), so the roof's gable band — * the triangle of wall above the plate that the roof kind builds — is skinned - * like the walls it sits on instead of the bare drywall default. The most - * common ref among the level's walls wins; null when none declares one. + * like the walls it sits on instead of the bare drywall default. The walls are + * the roof's level's, or for a roof on a level of its own (a building's roof + * level), the storey's directly below. The most common ref among them wins; + * null when none declares one. */ export function levelWallCladdingRef( nodes: Readonly>, @@ -81,10 +87,17 @@ export function levelWallCladdingRef( ): string | null { const levelId = roof.parentId if (!levelId) return null + const wallsOn = (id: string | null) => + Object.values(nodes).filter( + (node): node is WallNode => node?.type === 'wall' && node.parentId === id, + ) + const own = wallsOn(levelId) + const walls = own.length + ? own + : wallsOn(findLevelBelowId(levelId, getLevelElevations(nodes as Record))) const counts = new Map() - for (const node of Object.values(nodes)) { - if (node?.type !== 'wall' || node.parentId !== levelId) continue - const ref = wallAssemblyFinishRef(node) + for (const wall of walls) { + const ref = wallAssemblyFinishRef(wall) if (ref) counts.set(ref, (counts.get(ref) ?? 0) + 1) } let best: string | null = null diff --git a/plugin-evals/tool-annotation-justifications.json b/plugin-evals/tool-annotation-justifications.json index 44d8e66686..637b30d6a8 100644 --- a/plugin-evals/tool-annotation-justifications.json +++ b/plugin-evals/tool-annotation-justifications.json @@ -235,7 +235,7 @@ "openWorldHint": false }, "justifications": { - "readOnlyHint": "It is not read-only because adds a roof node to the connected Pascal scene.", + "readOnlyHint": "It is not read-only because it adds a roof, and the building's roof level when it has none, to the connected Pascal scene.", "destructiveHint": "Its intended operation adds new modeled state without deleting or replacing existing Pascal scene or project state.", "openWorldHint": "It operates on the connected Pascal scene, project, and configured catalog data without accessing the public internet." } diff --git a/skills/pascal-3d/SKILL.md b/skills/pascal-3d/SKILL.md index f0c4da064d..4e9540b8b1 100644 --- a/skills/pascal-3d/SKILL.md +++ b/skills/pascal-3d/SKILL.md @@ -59,7 +59,9 @@ For construction, prefer tools such as `create_story_shell`, `create_room`, `add To give a surface a finish, call `paint` as the editor's Paint does: the targets, a `role` when it is not the default surface (a wall's `exterior` or `interior` face, a slot, a roof's `top`, `edge` or `wall`), and one finish: a library material whose `library:` you found with `search_materials`, a `color` (painted with the nearest library colour), or a native material for an exact finish, kept as a scene material and reused when an equal one exists. An unknown target, role or material refuses the whole call and nothing is painted. -A window `style` (double-hung, grid, transom…) shapes a Fixed window's panes. For a casement, sliding, hung, awning, louvered, bay or bow window, pass its `windowType` without a style: those types draw their own sashes, and a style there is refused (`style_needs_fixed_window`). `picture` is an alias of `single` (one pane): both are accepted and draw the same. Where a photo's glass meets at a corner, with no wall between the panes, build one corner window with `add_corner_window` (both sides, the glass fused or at a post) instead of two windows near the corner; the window panel's Corner section wraps and unwraps one by hand. +A roof lives on the level directly above the storey whose walls carry it; over the top storey that is the building's roof level, which `create_roof` makes (named Roof) or reuses, so exploded and solo level views lift the roof off the plan. Call `create_roof` once the storeys and their exterior walls stand; it refuses a second roof over a storey (`roof_exists`) and a storey with another above it (`storey_above`). `add_level` puts a new storey under the roof level. Never write a roof onto a storey's own level with `apply_patch`: `verify_scene` reports it (`roof_mixed_in_storey`), and a roof level below a storey (`roof_level_not_on_top`). + +A window `style` (double-hung, grid, transom…) shapes a Fixed window's panes. For a casement, sliding, hung, awning, louvered, bay or bow window, pass its `windowType` without a style: those types draw their own sashes, and a style there is refused (`style_needs_fixed_window`). `picture` is an alias of `single` (one pane): both are accepted and draw the same. Where a photo's glass meets at a corner, with no wall between the panes, build one corner window with `add_corner_window` (both sides, the glass fused or at a post) instead of two windows near the corner; `merge_windows` joins two windows that already exist, and the window panel's Corner section wraps and unwraps one by hand. To add a stair, call `create_stair` on the floor it rises from: `(x, z)` is the back-centre of the bottom step and `rotation` the climb direction. It sizes the flight as the editor's stair tool does (about 18 cm risers, the run from the design targets) unless you give `length` or `steps`, creates the level above when there is none, and cuts the floor it arrives through and the ceiling it leaves. It refuses a flight to or from a roof level (`roof_level`), a `toLevelId` that is not above (`not_above`), and a given slab or ceiling that is not on the floor it arrives on or leaves (`slab_not_on_level`, `ceiling_not_on_level`). diff --git a/wiki/architecture/vertical-model.md b/wiki/architecture/vertical-model.md index 2d2be9bd70..02aa89a998 100644 --- a/wiki/architecture/vertical-model.md +++ b/wiki/architecture/vertical-model.md @@ -23,7 +23,7 @@ The invariant, in one sentence: | `level.baseElevation` | Additive offset from the computed stack position. It shifts this level and cumulatively shifts every higher level in the same building; negative offsets are valid. | Zero (the schema default). | | `wall.height` | Equal to the stored `level.height` within 1e-6 m: storey intent, unless ground-hosted. Otherwise an explicit body height above elected structural support, including below datum. | **Plane-bound**: follows the footprint-adjusted storey plane, clamped to covering-slab undersides. | | `ceiling.height` | Explicit custom height, write-clamped to the bound. | **Follows the level**: resolves live to `getCeilingClampBound` = `min(level height, covering underside) − 0.01`. | -| `roof.support.kind` | `walls` follows the highest spatially matched wall top in the roof’s level frame; `level` keeps custom Y; `roof` retains its roof-surface attachment rule. Room and curved-wall creation write `walls`; free-drawn rectangles write `level` at Y 0. | Existing roofs remain custom (`level`); no load migration enables following. | +| `roof.support.kind` | `walls` follows the highest spatially matched wall top in the roof’s level frame; `level` keeps custom Y; `roof` retains its roof-surface attachment rule. Room and curved-wall creation and the agents' `create_roof` write `walls`; free-drawn rectangles write `level` at Y 0 (see [Roof levels](#roof-levels)). | Existing roofs remain custom (`level`); no load migration enables following. | | `slab.floorHeight` | Ground-contact base plate authored floor top, in level-local metres. Upper plates author the top through thickness with a fixed underside. | Automatic floor datum from its supports. | | `slab.foundation` | Base plate support to terrain at site contact: solid or none, with a separate material and paint target. | No foundation. | | `slab.elevation` | Manual slab walking surface intent, level-local; derived geometry on floor plates. | Default 0.05. | @@ -197,6 +197,22 @@ Room-facing skirting follows the room floor and exterior skirting follows the base. Structural steps and open edges paint `zone.floorStepFinish` and `zone.floorEdgeFinish`; the default step finish is the upper room's floor finish. +## Roof levels + +A roof lives on the level directly above the storey whose walls carry it; over a building's +top storey that is its roof level (`metadata.role: 'roof'`, named Roof), kept above every storey, +so exploded and solo level views lift the roof off the plan. One core service, +`services/roof-level.ts`, states it for every roof writer: `planRoofLevel` (the level above, or +the roof level to reuse, declare or create), `roofsOver` (the roofs over a storey), +`storeyInsertIndex` (a new top storey goes under the roof level, which moves up one) and +`isRoofLevel`. The agents' `create_roof` and the editor's roof tool (a room's footprint, a +rectangle drawn on a walled storey, a cone over a curved wall) place roofs by it; `add_level` and +the editor's "add a floor" insert under the roof level. A level that holds only roofs (written +before levels declared their role) counts as the roof level and is declared when reused. +`verify_scene` reports a roof mixed into a storey (`roof_mixed_in_storey`) and a roof level below a +storey (`roof_level_not_on_top`); existing scenes are not migrated. Unpainted gables on a roof +level take the cladding of the storey's walls below (`levelWallCladdingRef`). + ## Stacked mezzanine rooms A mezzanine is a room zone with `floor.support: 'open'`, an explicit From 082e6bc6ecb2061b9ad03ecf35e7f8d5d155561d Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Mon, 12 Oct 2026 16:22:04 -0400 Subject: [PATCH 03/16] feat(nodes): a window dragged in the plan joins a corner as in 3D The 3D window move snapped a window to a corner and joined it to the one waiting on the other wall; the plan's move did not, so the same drag gave two results by view. The 2D move now runs the same corner evaluation: the edge snaps to the corner, the waiting window is outlined, and the drop joins them in one undo step. Alt drops it plain. Co-Authored-By: Claude Opus 5.5 --- .../nodes/src/window/floorplan-move.test.ts | 113 ++++++++++++++++++ packages/nodes/src/window/floorplan-move.ts | 62 +++++++--- wiki/architecture/tools.md | 2 +- 3 files changed, 162 insertions(+), 15 deletions(-) create mode 100644 packages/nodes/src/window/floorplan-move.test.ts diff --git a/packages/nodes/src/window/floorplan-move.test.ts b/packages/nodes/src/window/floorplan-move.test.ts new file mode 100644 index 0000000000..f142771680 --- /dev/null +++ b/packages/nodes/src/window/floorplan-move.test.ts @@ -0,0 +1,113 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeId, + BuildingNode, + cornerPair, + LevelNode, + useScene, + WallNode, + WindowNode, +} from '@pascal-app/core' +import { windowFloorplanMoveTarget } from './floorplan-move' + +// 2D and 3D share the corner join: a window dragged in the plan to a corner where another waits +// on the other wall lands joined to it, as the 3D move does; Alt drops it plain. +const LEVEL = 'level_plan-corner' + +function seed() { + const south = WallNode.parse({ + id: 'wall_south', + parentId: LEVEL, + start: [0, 0], + end: [6, 0], + height: 2.5, + children: ['window_moving'], + }) + const east = WallNode.parse({ + id: 'wall_east', + parentId: LEVEL, + start: [6, 0], + end: [6, 5], + height: 2.5, + children: ['window_waiting'], + }) + const moving = WindowNode.parse({ + id: 'window_moving', + parentId: south.id, + wallId: south.id, + position: [2, 1.25, 0], + width: 1.2, + height: 1.2, + }) + const waiting = WindowNode.parse({ + id: 'window_waiting', + parentId: east.id, + wallId: east.id, + position: [0.6, 1.25, 0], + width: 1.2, + height: 1.2, + }) + const level = LevelNode.parse({ + id: LEVEL, + parentId: 'building_plan-corner', + level: 0, + height: 2.5, + children: [south.id, east.id], + }) + const building = BuildingNode.parse({ id: 'building_plan-corner', children: [level.id] }) + useScene.setState({ + nodes: Object.fromEntries( + [building, level, south, east, moving, waiting].map((node) => [node.id, node as AnyNode]), + ), + rootNodeIds: [building.id], + collections: {}, + dirtyNodes: new Set(), + } as never) + return moving +} + +const drop = (moving: WindowNode, planPoint: [number, number], altKey = false) => { + const session = windowFloorplanMoveTarget({ + node: moving, + nodes: useScene.getState().nodes as Record, + }) + session.apply({ + planPoint, + modifiers: { shiftKey: false, altKey, ctrlKey: false, metaKey: false }, + }) + expect(session.canCommit()).toBe(true) + session.commit?.() +} + +const windowNode = (id: string) => useScene.getState().nodes[id as AnyNodeId] as WindowNode + +describe('2D window move near a corner', () => { + const originalRaf = globalThis.requestAnimationFrame + const originalCancelRaf = globalThis.cancelAnimationFrame + beforeEach(() => { + globalThis.requestAnimationFrame = () => 0 + globalThis.cancelAnimationFrame = () => {} + useScene.temporal.getState().clear() + useScene.temporal.getState().resume() + }) + afterEach(() => { + globalThis.requestAnimationFrame = originalRaf + globalThis.cancelAnimationFrame = originalCancelRaf + }) + + test('joins the window waiting on the other wall', () => { + const moving = seed() + drop(moving, [5.3, 0]) + const nodes = useScene.getState().nodes as Record + const pair = cornerPair(nodes, windowNode('window_moving')) + expect(pair?.partner.id).toBe('window_waiting') + expect(windowNode('window_moving').position[0]).toBeCloseTo(5.4, 6) + }) + + test('Alt drops it plain', () => { + const moving = seed() + drop(moving, [5.3, 0], true) + expect(windowNode('window_moving').corner).toBeUndefined() + }) +}) diff --git a/packages/nodes/src/window/floorplan-move.ts b/packages/nodes/src/window/floorplan-move.ts index eee6309240..d73397471a 100644 --- a/packages/nodes/src/window/floorplan-move.ts +++ b/packages/nodes/src/window/floorplan-move.ts @@ -1,7 +1,10 @@ import { type AnyNodeId, + type CornerSnap, + evaluateCorner, type FloorplanMoveTarget, type FloorplanMoveTargetSession, + runAsSingleSceneHistoryStep, useLiveNodeOverrides, useLiveTransforms, useScene, @@ -26,6 +29,8 @@ import { resolveOpeningPlacement, snapLocalXToNeighbors, } from '../shared/wall-attach-target' +import { showCornerCue } from './corner-cue' +import { fuseAtCorner } from './corner-fuse' import { clampToWall, DEFAULT_WINDOW_SILL_M, hasWallChildOverlap } from './window-math' /** @@ -97,6 +102,9 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod // Alt force-place (last apply's modifier) — lets `canCommit` allow an // overlapping placement, matching the 3D move. let forcePlace = false + // Near a corner, as in the 3D move: the edge snaps to it, the window waiting on the other wall + // is outlined, and the drop joins them. Alt drops it plain. + let cornerSnap: CornerSnap | null = null let liveTransformActive = useLiveTransforms.getState().transforms.has(nodeId) let liveOverrideKey: string | null = null let placementPreviewActive = usePlacementPreview.getState().node?.id === nodeId @@ -126,6 +134,8 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod const freeFollow = (planPoint: readonly [number, number]) => { onWall = false lastValid = null + cornerSnap = null + showCornerCue(null) if (liveTransformActive) { useLiveTransforms.getState().clear(nodeId) liveTransformActive = false @@ -197,14 +207,37 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod nodes, }) const snappedLocalX = neighborX ?? snapToHalf(hit.localX) - const { clampedX, clampedY } = clampToWall( - hit.wall, - snappedLocalX, - startLocalY, - node.width, - node.height, - nodes, - ) + const slid = clampToWall(hit.wall, snappedLocalX, startLocalY, node.width, node.height, nodes) + const { clampedY } = slid + let clampedX = slid.clampedX + const corner = forcePlace + ? { snap: null, block: null } + : evaluateCorner(nodes, { + wallId: hit.wall.id, + centreX: clampedX, + width: node.width, + height: node.height, + sillHeight: clampedY - node.height / 2, + ignoreId: nodeId, + held: cornerSnap?.end ?? null, + }) + cornerSnap = corner.snap + // A curtain wall's margin can keep the window off its corner: then it is no corner. + if (cornerSnap) { + const settled = clampToWall( + hit.wall, + cornerSnap.centreX, + clampedY, + node.width, + node.height, + nodes, + ) + if (Math.abs(settled.clampedX - cornerSnap.centreX) > 1e-6) { + corner.block = { reason: 'curtain', end: cornerSnap.end } + cornerSnap = null + } else clampedX = cornerSnap.centreX + } + showCornerCue(cornerSnap, corner.block) // One click per real position step, keyed on the SNAPPED along-wall value // so it ticks only when the window actually moves to a new cell. @@ -246,6 +279,7 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod liveTransformActive = true }, canCommit() { + showCornerCue(null) // Off-wall the window is free-following — not placeable; the overlay // reverts to the pre-move snapshot. Matches the 3D move. if (!onWall || !lastValid) return false @@ -273,12 +307,12 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod // committed move lands on the same `parentId` with identical data. // See `doorFloorplanMoveTarget.commit` for the original fix. if (!lastValid) return - useScene.getState().updateNodes([ - { - id: nodeId, - data: lastValid, - }, - ]) + const placed = lastValid + // At a corner the window lands already joined, as one undo step. + runAsSingleSceneHistoryStep(useScene, () => { + useScene.getState().updateNodes([{ id: nodeId, data: placed }]) + if (cornerSnap) fuseAtCorner(nodeId, cornerSnap.waitingId) + }) }, } diff --git a/wiki/architecture/tools.md b/wiki/architecture/tools.md index 03dcefc927..e3f2ed7da4 100644 --- a/wiki/architecture/tools.md +++ b/wiki/architecture/tools.md @@ -139,7 +139,7 @@ Concretely, door/window placement/move keeps these in lockstep across `{door,win - **Snap target**: nearest wall to the true cursor (shared `findClosestWallInPlan` / wall raycast), free-follow off-wall, commit only on a host. - **Move SFX**: a soft `sfx:grid-snap` click per grid step while sliding (free-follow plan XZ or on-wall along-X, quantized + deduped so it isn't a machine-gun) and a soft `sfx:item-pick` cue on the floor→wall snap. Both tools carry an identical `tickGridStep` / `tickWallSnap` pair — keep them in sync. - **R-flip** facing mid-placement, **Alt (hold)** to force-place past snapping and collisions (guides stay visible) and **Shift (tap)** to cycle the snapping mode — the rule above, never a held-Shift bypass — faithful ghost/symbol, deterministic single-undo commit. -- **Corner windows.** A window that ends at a corner has a **Corner** section in its panel: *Wrap the corner* adds the matching window on the other wall (the same width, height, sill, type and frame), *This side* and *Other side* set each side's width with its corner end pinned, *None* or *Post* sets the corner (the glass fused, or a thin post), and *Unwrap* leaves two plain windows. The agent's `add_corner_window` builds the same pair (core `planCornerWindow`, `wrapCorner`, `unwrapCorner`); the wall cut runs past the wall's end so the corner opens between the sides at any angle. **Merge windows** (core `mergeWindows`, the agent's `merge_windows`, one operation for both surfaces): select two windows and use *Merge windows* in the multi-selection panel (the panel gives the reason when it will not merge). Two windows on walls that meet at a corner, each within `CORNER_REACH_M` (0.30 m) of it, become one corner window: each slides to the corner keeping its width, the glass is fused, and both take the first selected window's height, sill and type. Two windows side by side on one wall, at most `MERGE_GAP_M` (0.30 m) apart with nothing between, become one window spanning the outer edges, from the lower sill to the higher head. *Wrap the corner* uses the same operation, so a window already waiting at the other wall's corner end is adopted rather than doubled. While placing or dragging a window near a corner where one waits, the ghost snaps its edge to the corner (`cornerSnap`: enter within 0.30 m, leave past 0.40 m so it does not flicker; a short ease, none under reduced motion), the waiting window is outlined with the hover colour, the HUD reads *Join as a corner window*, and the click or release fuses the pair in the same undo step. Where no window waits the snap still shows, the HUD reads *Corner window*, and the click or release wraps the corner, making the partner on the other wall. Where a wall end within 0.30 m is not a corner a window can wrap, the HUD says why instead of staying silent (`cornerBlock`): no other wall ends there, three or more do, they meet at under 10° or over 170°, either wall is curved or a curtain wall, or the other wall has no room for the matching window. **Alt** places or moves it plain. +- **Corner windows.** A window that ends at a corner has a **Corner** section in its panel: *Wrap the corner* adds the matching window on the other wall (the same width, height, sill, type and frame), *This side* and *Other side* set each side's width with its corner end pinned, *Fused glass* or *Corner post* sets the corner, and *Unwrap* leaves two plain windows. The agent's `add_corner_window` builds the same pair (core `planCornerWindow`, `wrapCorner`, `unwrapCorner`); the wall cut runs past the wall's end so the corner opens between the sides at any angle. **Merge windows** (core `mergeWindows`, the agent's `merge_windows`, one operation for both surfaces): select two windows and use *Merge windows* in the multi-selection panel (the panel gives the reason when it will not merge). Two windows on walls that meet at a corner, each within `CORNER_REACH_M` (0.30 m) of it, become one corner window: each slides to the corner keeping its width, the glass is fused, and both take the first selected window's height, sill and type. Two windows side by side on one wall, at most `MERGE_GAP_M` (0.30 m) apart with nothing between, become one window spanning the outer edges, from the lower sill to the higher head. *Wrap the corner* uses the same operation, so a window already waiting at the other wall's corner end is adopted rather than doubled. While placing or dragging a window near a corner where one waits (in 3D, and dragging it in the plan), the ghost snaps its edge to the corner (`cornerSnap`: enter within 0.30 m, leave past 0.40 m so it does not flicker; a short ease, none under reduced motion), the waiting window is outlined with the hover colour, the HUD reads *Join as a corner window*, and the click or release fuses the pair in the same undo step. Where no window waits the snap still shows, the HUD reads *Corner window*, and the click or release wraps the corner, making the partner on the other wall. Where a wall end within 0.30 m is not a corner a window can wrap, the HUD says why instead of staying silent (`cornerBlock`): no other wall ends there, three or more do, they meet at under 10° or over 170°, either wall is curved or a curtain wall, or the other wall has no room for the matching window. **Alt** places or moves it plain. - **O / L** cycle the door and window tools' Type and Style chips (`ToolHint.chip`, by key or click): the choices of the panel's Type and Style rows, written from the same core fields (`doorTypeFields`, `DOOR_TYPE_SIZES` and `doorStyleLook` for a door; `windowTypeFields` and the window style presets for a window), so a door placed by hand matches one changed in the panel. A window placed as Bay or Bow has no sill; every other type has one. A window style shapes a Fixed window's panes, which only a Fixed window draws, so the panel's Style row and the tool's L chip show for a Fixed window only (cycling O away from Fixed hides L), and `add_window` refuses a style on another type (`style_needs_fixed_window`). The Style row and the L chip offer one entry per look (`WINDOW_STYLE_CHOICES`); `picture`, which draws what `single` draws, stays an accepted alias for agents. Tells that you've broken parity: a sound/guide/snap that fires in 3D but is silent in 2D (or vice-versa), or a fix landed in one move file but not its sibling. The two move files are deliberately near-mirrors; diff them when in doubt. From 936d10455d0554a4153b8c60344283756d30b941 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Mon, 12 Oct 2026 20:57:43 -0400 Subject: [PATCH 04/16] fix(core): a roof level only tops its stack; overlapping plates are one footprint; create_roof asks which building - planRoofLevel declares an empty level the roof level only when nothing stands above it: an empty level under a storey is a storey not built yet, and roofing the floor below no longer turns it into a Roof level under that storey. A declared roof level someone walled counts as a storey. - A footprint is the union of its floor plates: two slabs that overlap roof their overlap (it was an exclusive-or, which split one house into two wings). - With no level named and none in view (the MCP), create_roof on a site of several buildings answers building_required with their ids, instead of roofing whichever sorts last. - storey_above no longer tells the agent to roof the top storey instead: a lower wing's roof stands on its own walls, so it says the person draws it with the Roof tool. Co-Authored-By: Claude Opus 5.5 --- .../__fixtures__/roof-cases.ts | 88 +++++++++++++++++++ .../core/src/agent-operations/create-roof.ts | 31 ++++--- .../src/agent-operations/roof-footprint.ts | 21 +++-- packages/core/src/agent-tools/roofs.ts | 2 +- packages/core/src/services/roof-level.test.ts | 18 ++++ packages/core/src/services/roof-level.ts | 21 +++-- wiki/architecture/vertical-model.md | 8 +- 7 files changed, 156 insertions(+), 33 deletions(-) diff --git a/packages/core/src/agent-operations/__fixtures__/roof-cases.ts b/packages/core/src/agent-operations/__fixtures__/roof-cases.ts index e81b2b2be1..9ce488332b 100644 --- a/packages/core/src/agent-operations/__fixtures__/roof-cases.ts +++ b/packages/core/src/agent-operations/__fixtures__/roof-cases.ts @@ -4,6 +4,7 @@ import { LevelNode, RoofNode, RoofSegmentNode, + SlabNode, WallNode, ZoneNode, } from '../../schema' @@ -170,6 +171,93 @@ export const CREATE_ROOF_CASES: AgentToolCase[] = [ input: { levelId: 'level_g' }, expect: { refusal: 'storey_above', mentions: ['level_1'] }, }, + { + name: 'an empty level with a storey above it is not made a roof level under that storey', + tool: 'create_roof', + scene: () => { + const nodes = house({ storeys: 2 })().nodes as Record + const building = nodes.building_r as BuildingNode + const empty = LevelNode.parse({ id: 'level_e', parentId: 'building_r', level: 1 }) + const upper = nodes.level_1 as LevelNode + return graph( + { ...building, children: ['level_g', 'level_e', 'level_1'] }, + ...Object.values(nodes).filter((node) => node.type !== 'building' && node.id !== 'level_1'), + empty, + { ...upper, level: 2 }, + ) + }, + input: { levelId: 'level_g' }, + expect: { refusal: 'storey_above', mentions: ['level_e', 'Roof tool'] }, + }, + { + name: 'two floor plates that overlap are one footprint, their overlap roofed', + tool: 'create_roof', + scene: () => + graph( + BuildingNode.parse({ id: 'building_r', children: ['level_g'] }), + LevelNode.parse({ + id: 'level_g', + parentId: 'building_r', + level: 0, + height: 2.5, + children: ['slab_west', 'slab_east'], + }), + SlabNode.parse({ + id: 'slab_west', + parentId: 'level_g', + polygon: [ + [0, 0], + [8, 0], + [8, 6], + [0, 6], + ], + }), + SlabNode.parse({ + id: 'slab_east', + parentId: 'level_g', + polygon: [ + [4, 0], + [12, 0], + [12, 6], + [4, 6], + ], + }), + ), + input: {}, + expect: { + result: { coveredLevelId: 'level_g', roofType: 'gable' }, + check: (result, nodes) => { + const segments = (result.segmentIds as string[]).map((id) => nodes[id]) + const [segment] = segments + return segments.length === 1 && + segment?.type === 'roof-segment' && + segment.width === 12 && + segment.depth === 6 + ? [] + : [`expected one 12 × 6 m segment, got ${JSON.stringify(segments)}`] + }, + }, + }, + { + name: 'no level named nor in view on a site of two buildings: which one is asked', + tool: 'create_roof', + scene: () => { + const one = house()() + const other = BuildingNode.parse({ id: 'building_o', children: ['level_o'] }) + const room = ZoneNode.parse({ id: 'zone_o', parentId: 'level_o', name: 'Shed', polygon: ROOM }) + const level = LevelNode.parse({ + id: 'level_o', + parentId: other.id, + level: 0, + height: 2.5, + children: [room.id], + }) + return graph(...(Object.values(one.nodes) as AnyNode[]), other, level, room) + }, + input: {}, + context: { activeLevelId: null }, + expect: { refusal: 'building_required', mentions: ['building_r', 'building_o'] }, + }, { name: 'a roof level named stands for the storey under it', tool: 'create_roof', diff --git a/packages/core/src/agent-operations/create-roof.ts b/packages/core/src/agent-operations/create-roof.ts index d3c7ecaf87..123d50cf19 100644 --- a/packages/core/src/agent-operations/create-roof.ts +++ b/packages/core/src/agent-operations/create-roof.ts @@ -90,22 +90,27 @@ function coveredStorey( ) return { storey: requested } const viewed = context.activeLevelId ? nodes[context.activeLevelId] : undefined - const candidates = ( - requested - ? buildingLevels(nodes, requested) - : viewed?.type === 'level' - ? buildingLevels(nodes, viewed) - : levelsOf(nodes) - ).filter((candidate) => isStorey(nodes, candidate)) + const scope = requested ?? (viewed?.type === 'level' ? viewed : null) + const candidates = (scope ? buildingLevels(nodes, scope) : levelsOf(nodes)).filter((candidate) => + isStorey(nodes, candidate), + ) + const eligible = rectangle + ? candidates.filter((candidate) => candidate.children.length > 0) + : candidates.filter((candidate) => footprintPolygons(nodes, candidate.id).length > 0) + // With no level in view (the MCP), the last level by ordinal would pick a building by its id. + const buildingIds = [...new Set(eligible.map((candidate) => candidate.parentId))] + if (!scope && buildingIds.length > 1) + refuse( + 'building_required', + `The scene has ${buildingIds.length} buildings to roof; pass a level of the one to roof: ${buildingIds.join(', ')}.`, + { buildingIds }, + ) if (rectangle) { - const top = - candidates.filter((candidate) => candidate.children.length > 0).at(-1) ?? candidates.at(-1) + const top = eligible.at(-1) ?? candidates.at(-1) if (!top) refuse('no_levels', 'The scene has no storey to roof yet.') return { storey: top } } - const storey = candidates - .filter((candidate) => footprintPolygons(nodes, candidate.id).length > 0) - .at(-1) + const storey = eligible.at(-1) if (!storey) refuse( 'no_rooms', @@ -214,7 +219,7 @@ export const createRoof: AgentOperation = (nodes, input, contex const above = nodes[plan.levelId] as Level refuse( 'storey_above', - `${storey.name ?? storey.id} (${storey.id}) has a storey above it, ${above.name ?? above.id} (${above.id}): a roof caps the top storey. Pass the top storey (with width and depth when it has no rooms).`, + `${storey.name ?? storey.id} (${storey.id}) has a storey above it, ${above.name ?? above.id} (${above.id}): create_roof caps the top storey, and its roof covers the floors under it. A roof over a lower wing or a porch of ${storey.name ?? storey.id} stands on its walls, below the top storey's: do not roof it from the top storey; the person draws it with the Roof tool on ${storey.name ?? storey.id}.`, { levelId: storey.id, storeyAboveId: above.id }, ) } diff --git a/packages/core/src/agent-operations/roof-footprint.ts b/packages/core/src/agent-operations/roof-footprint.ts index 3e7ab59e3d..f8a3e1a798 100644 --- a/packages/core/src/agent-operations/roof-footprint.ts +++ b/packages/core/src/agent-operations/roof-footprint.ts @@ -21,17 +21,20 @@ const MAX_HIP_WINGS = 6 /** A footprint longer than this is a block, not a house: its roof is flat. */ const BLOCK_LENGTH = 30 -function insideAny(polygons: readonly (readonly Pt[])[], x: number, z: number) { - let inside = false - for (const polygon of polygons) - for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { - const [ax, az] = polygon[i]! - const [bx, bz] = polygon[j]! - if (az > z !== bz > z && x < ((bx - ax) * (z - az)) / (bz - az) + ax) inside = !inside - } - return inside +function inside(polygon: readonly Pt[], x: number, z: number) { + let crossed = false + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const [ax, az] = polygon[i]! + const [bx, bz] = polygon[j]! + if (az > z !== bz > z && x < ((bx - ax) * (z - az)) / (bz - az) + ax) crossed = !crossed + } + return crossed } +/** The footprint is the union of its plates: where two slabs overlap, both cover it. */ +const insideAny = (polygons: readonly (readonly Pt[])[], x: number, z: number) => + polygons.some((polygon) => inside(polygon, x, z)) + /** * A strip of cells thinner than a part keeps only what its larger neighbour along the other axis * also has: balconies drop out and the wings they stand on stay whole. Along z, then along x. diff --git a/packages/core/src/agent-tools/roofs.ts b/packages/core/src/agent-tools/roofs.ts index 202303211b..fc50674396 100644 --- a/packages/core/src/agent-tools/roofs.ts +++ b/packages/core/src/agent-tools/roofs.ts @@ -7,7 +7,7 @@ export const createRoofTool = { name: 'create_roof', title: 'Create roof', description: - "Cap a storey with a roof over its footprint (its floor plates, else its rooms), swept in the building's own axes into rectangles, one roof segment each. Without a roofType: a gable for one rectangle, hips for a few wings, flat for a large block. The roof goes on the building's roof level, the level directly above the top storey (made, named Roof, when there is none), and stands on the storey's wall tops, so exploded and solo level views lift it off the plan. Call it once the storeys and their exterior walls stand. Refused with a code: storey_above (another storey stands above: a roof caps the top one), roof_exists (the storey already has a roof: edit it, or delete_node it first when the person asked for a new one), no_rooms (no rooms on any level of the building: pass width and depth to roof a rectangle of your own).", + "Cap a storey with a roof over its footprint (its floor plates, else its rooms), swept in the building's own axes into rectangles, one roof segment each. Without a roofType: a gable for one rectangle, hips for a few wings, flat for a large block. The roof goes on the building's roof level, the level directly above the top storey (made, named Roof, when there is none), and stands on the storey's wall tops, so exploded and solo level views lift it off the plan. Call it once the storeys and their exterior walls stand. Refused with a code: storey_above (another storey stands above: a roof caps the top one; a lower wing's roof is drawn with the Roof tool), building_required (no level named on a site of several buildings: pass a level of the one to roof), roof_exists (the storey already has a roof: edit it, or delete_node it first when the person asked for a new one), no_rooms (no rooms on any level of the building: pass width and depth to roof a rectangle of your own).", input: { levelId: NodeId.optional().describe( 'The storey to cover, by an id list_levels returned. Default: the top storey with rooms. A level without rooms stands for the highest storey of its building that has some.', diff --git a/packages/core/src/services/roof-level.test.ts b/packages/core/src/services/roof-level.test.ts index ecf0ab7cb4..1dbbf5fd88 100644 --- a/packages/core/src/services/roof-level.test.ts +++ b/packages/core/src/services/roof-level.test.ts @@ -99,6 +99,24 @@ describe('planRoofLevel', () => { if (plan.kind !== 'roof') return expect(plan.update[0]?.data).toEqual({ metadata: { role: 'roof' }, name: 'Attic' }) }) + + test('an empty level with a storey above it is a storey not built yet, not a roof level', () => { + const nodes = house( + { id: 'level_g', content: 'walls' }, + { id: 'level_e', content: 'empty' }, + { id: 'level_2', content: 'walls' }, + ) + expect(planRoofLevel(nodes, 'level_g', 2)).toEqual({ kind: 'storey', levelId: 'level_e' }) + }) + + test('a declared roof level someone walled is a storey', () => { + const nodes = house( + { id: 'level_g', content: 'walls' }, + { id: 'level_r', content: 'walls', metadata: { role: 'roof' } }, + ) + expect(planRoofLevel(nodes, 'level_g', 2)).toEqual({ kind: 'storey', levelId: 'level_r' }) + expect(storeyInsertIndex(nodes, 'building_h')).toEqual({ floorIndex: 2, shift: [] }) + }) }) describe('roofsOver', () => { diff --git a/packages/core/src/services/roof-level.ts b/packages/core/src/services/roof-level.ts index 4e34ef27af..cc911604cb 100644 --- a/packages/core/src/services/roof-level.ts +++ b/packages/core/src/services/roof-level.ts @@ -33,14 +33,14 @@ const roofsOn = (nodes: Nodes, level: LevelNode) => childrenOf(nodes, level).filter((child): child is RoofNode => child.type === 'roof') /** - * A level that is its building's roof level: declared, or holding roofs and nothing a storey holds - * (a roof level written before levels declared it). + * A level that is its building's roof level: declared, or holding roofs (a roof level written + * before levels declared it), and holding nothing a storey holds: a declared level someone walled + * is a storey. */ function servesAsRoofLevel(nodes: Nodes, level: LevelNode): boolean { - if (isRoofLevel(level)) return true const children = childrenOf(nodes, level) return ( - children.some((child) => child.type === 'roof') && + (isRoofLevel(level) || children.some((child) => child.type === 'roof')) && !children.some((child) => STOREY_CONTENT.has(child.type)) ) } @@ -62,15 +62,20 @@ export type RoofLevelPlan = /** * Where a roof carried by `storeyId`'s walls lives. Directly above the storey stands either a * storey (the roof covers part of the storey below it: a porch, a lower wing) or the building's - * roof level: the level there when it serves as one, or is empty, declared if it was not; else a - * new "Roof" level `roofHeight` tall (the roof's rise over its eaves). Pure: the caller writes the - * plan in the same step as the roof. + * roof level: the level there when it serves as one, or is empty and the top of the stack, declared + * if it was not; else a new "Roof" level `roofHeight` tall (the roof's rise over its eaves). An + * empty level with another above it is a storey not built yet, never a roof level under a floor. + * Pure: the caller writes the plan in the same step as the roof. */ export function planRoofLevel(nodes: Nodes, storeyId: string, roofHeight: number): RoofLevelPlan { const storey = nodes[storeyId] if (storey?.type !== 'level') throw Error(`${storeyId} is not a level`) const above = levelAbove(nodes, storeyId) - if (above && !servesAsRoofLevel(nodes, above) && above.children.length > 0) + if ( + above && + !servesAsRoofLevel(nodes, above) && + (above.children.length > 0 || levelAbove(nodes, above.id)) + ) return { kind: 'storey', levelId: above.id } if (above) return { diff --git a/wiki/architecture/vertical-model.md b/wiki/architecture/vertical-model.md index 02aa89a998..e032a1c489 100644 --- a/wiki/architecture/vertical-model.md +++ b/wiki/architecture/vertical-model.md @@ -207,8 +207,12 @@ the roof level to reuse, declare or create), `roofsOver` (the roofs over a store `storeyInsertIndex` (a new top storey goes under the roof level, which moves up one) and `isRoofLevel`. The agents' `create_roof` and the editor's roof tool (a room's footprint, a rectangle drawn on a walled storey, a cone over a curved wall) place roofs by it; `add_level` and -the editor's "add a floor" insert under the roof level. A level that holds only roofs (written -before levels declared their role) counts as the roof level and is declared when reused. +the editor's "add a floor" insert under the roof level, and the level selector refuses a drop that +puts a roof level under a storey. A level that holds only roofs (written before levels declared +their role) counts as the roof level and is declared when reused; an empty level becomes the roof +level only at the top of its stack (with a storey above it, it is a storey not built yet), and a +declared roof level someone walled counts as a storey. A roof placed by hand on the level above +selects that level with it, so it stays in view. `verify_scene` reports a roof mixed into a storey (`roof_mixed_in_storey`) and a roof level below a storey (`roof_level_not_on_top`); existing scenes are not migrated. Unpainted gables on a roof level take the cladding of the storey's walls below (`levelWallCladdingRef`). From 068b0c64fe3211e979542bbbbfb2bd233600bcc7 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Mon, 12 Oct 2026 20:57:43 -0400 Subject: [PATCH 05/16] fix(editor): a roof placed by hand stays in view; the site plan draws roofs on the roof level - A tool's selectNode selects a node on its own level when that is not the one in view, in 2D and 3D. A roof drawn over the ground floor's walls lands on the Roof level, which the editor hides while the ground floor is selected: it disappeared the moment it was placed. - The site plan's dashed roof line reads every level of the building, not only the lowest, so a roof on the roof level is drawn. - The level selector inserts a floor between two levels in one undo step (undo also moves the levels above back), and refuses a drag that puts a roof level under a storey. Co-Authored-By: Claude Opus 5.5 --- .../floorplan-registered-tool-layer.tsx | 4 +- .../src/components/tools/tool-manager.tsx | 5 +- .../components/ui/floating-level-selector.tsx | 25 ++++---- .../site-plan/build-site-plan-drawing.ts | 7 ++- .../site-plan/site-annotations.test.ts | 58 +++++++++++++++++++ .../editor/src/lib/level-selection.test.ts | 57 ++++++++++++++++++ packages/editor/src/lib/level-selection.ts | 17 +++++- 7 files changed, 154 insertions(+), 19 deletions(-) create mode 100644 packages/editor/src/lib/level-selection.test.ts diff --git a/packages/editor/src/components/editor-2d/floorplan-registered-tool-layer.tsx b/packages/editor/src/components/editor-2d/floorplan-registered-tool-layer.tsx index c30bdcf6fc..7085f27c3a 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registered-tool-layer.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registered-tool-layer.tsx @@ -12,6 +12,7 @@ import { type FloorplanMode, isFloorplanToolAvailableInMode, } from '../../lib/floorplan/floorplan-mode' +import { selectPlacedNode } from '../../lib/level-selection' import useEditor from '../../store/use-editor' import useFloorplanMode from '../../store/use-floorplan-mode' import useInteractionScope, { useReshapingNode } from '../../store/use-interaction-scope' @@ -65,8 +66,7 @@ export function FloorplanRegisteredToolLayer() { const metricNotation = useViewer((state) => state.metricNotation) const sceneApi = useMemo(() => createSceneApi(useScene), []) const selectNode = useCallback( - (id: Parameters[0]) => - useViewer.getState().setSelection({ selectedIds: [id] }), + (id: Parameters[0]) => selectPlacedNode(id), [], ) const finishTool = useCallback(() => { diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index ed656733bf..1a2a1349dc 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -16,6 +16,7 @@ import { type ComponentType, lazy, Suspense, useMemo } from 'react' import { useRegisteredToolEnabled } from '../../hooks/use-registered-tool-enabled' import { useCeilingEditCeilingId } from '../../lib/ceiling-edit-session' import type { ReshapeKind } from '../../lib/interaction/scope' +import { selectPlacedNode } from '../../lib/level-selection' import { paintRegionTargets, usePaintRegionMode } from '../../lib/paint-region-mode' import { siteBoundaryHandlesEnabled } from '../../lib/site-boundary' import useEditor, { type Tool } from '../../store/use-editor' @@ -184,10 +185,10 @@ export const ToolManager: React.FC = () => { activeLevelId: activeLevelId ?? null, isCameraDragging: () => useViewer.getState().cameraDragging, sceneApi: registrySceneApi, - selectNode: (nodeId: AnyNodeId) => setSelection({ selectedIds: [nodeId] }), + selectNode: selectPlacedNode, unit, }), - [activeLevelId, registrySceneApi, setSelection, unit], + [activeLevelId, registrySceneApi, unit], ) // Building transform for the local group — all building-relative tools live inside this group diff --git a/packages/editor/src/components/ui/floating-level-selector.tsx b/packages/editor/src/components/ui/floating-level-selector.tsx index 66397f1fb2..2ea021a25b 100644 --- a/packages/editor/src/components/ui/floating-level-selector.tsx +++ b/packages/editor/src/components/ui/floating-level-selector.tsx @@ -51,7 +51,7 @@ import { buildLevelDuplicateCreateOps, type LevelDuplicatePreset, } from '@pascal-app/core/building' -import { getDefaultLevelName, getLevelDisplayName } from '@pascal-app/core' +import { getDefaultLevelName, getLevelDisplayName, isRoofLevel } from '@pascal-app/core' import { addStoreyOnTop } from '../../lib/add-storey' import { deleteLevelWithFallbackSelection } from '../../lib/level-selection' import { unitMemberLevels, leaveUnitFocus } from '../../lib/units' @@ -527,26 +527,22 @@ export function FloatingLevelSelector() { if (!lower) return const newLevelNumber = lower.level + 1 - const toShift = levels.filter((l) => l.level >= newLevelNumber) - if (toShift.length > 0) { - updateNodes( - toShift.map((l) => ({ - id: l.id as AnyNodeId, - data: { level: l.level + 1 } as Partial, - })), - ) - } - const newLevel = LevelNode.parse({ level: newLevelNumber, height: DEFAULT_LEVEL_HEIGHT, children: [], parentId: resolvedBuildingId, }) - createNode(newLevel, resolvedBuildingId) + // One undo step: undoing the floor also moves the levels above back down. + applyNodeChanges({ + create: [{ node: newLevel, parentId: resolvedBuildingId }], + update: levels + .filter((l) => l.level >= newLevelNumber) + .map((l) => ({ id: l.id as AnyNodeId, data: { level: l.level + 1 } as Partial })), + }) setSelection({ buildingId: resolvedBuildingId, levelId: newLevel.id }) }, - [resolvedBuildingId, levels, createNode, updateNodes, setSelection], + [resolvedBuildingId, levels, applyNodeChanges, setSelection], ) const handleConfirmDelete = useCallback(() => { @@ -595,6 +591,9 @@ export function FloatingLevelSelector() { if (oldIndex === -1 || newIndex === -1) return const reorderedVisualLevels = arrayMove(visualLevels, oldIndex, newIndex) + // The roof level stays above every storey (top first here): a drop under a storey is refused. + const firstStorey = reorderedVisualLevels.findIndex((level) => !isRoofLevel(level)) + if (firstStorey >= 0 && reorderedVisualLevels.slice(firstStorey + 1).some(isRoofLevel)) return const levelNumbersDescending = levels.map((level) => level.level).sort((a, b) => b - a) const updates = reorderedVisualLevels diff --git a/packages/editor/src/lib/floorplan/site-plan/build-site-plan-drawing.ts b/packages/editor/src/lib/floorplan/site-plan/build-site-plan-drawing.ts index f5829f9d28..3236f13cab 100644 --- a/packages/editor/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +++ b/packages/editor/src/lib/floorplan/site-plan/build-site-plan-drawing.ts @@ -47,6 +47,7 @@ import { streetEdgeNames, } from './site-annotations' import { + aboveGradeLevels, findBuilding, findLowestLevel, findSite, @@ -566,7 +567,11 @@ export function buildSitePlanDrawing(scene: SceneSnapshot): SitePlanDrawing { } // ── The roof line, dashed over the footprint ───────────────────────── - for (const ring of roofOutlineRings(scene, level, building)) { + // Every storey's roofs, and the roof level's above them all. + const roofRings = aboveGradeLevels(scene, building).flatMap((roofed) => + roofOutlineRings(scene, roofed, building), + ) + for (const ring of roofRings) { primitives.push({ kind: 'polygon', points: ring, diff --git a/packages/editor/src/lib/floorplan/site-plan/site-annotations.test.ts b/packages/editor/src/lib/floorplan/site-plan/site-annotations.test.ts index ea30099f0d..6820423ded 100644 --- a/packages/editor/src/lib/floorplan/site-plan/site-annotations.test.ts +++ b/packages/editor/src/lib/floorplan/site-plan/site-annotations.test.ts @@ -278,6 +278,64 @@ describe('coverage: one computation for the site plan and the cover', () => { }) }) +describe('the roof line', () => { + test('a roof on the roof level above the storey is drawn dashed over the footprint', () => { + const nodes = { + site_1: { + id: 'site_1', + type: 'site', + polygon: { type: 'polygon', points: lot30() }, + children: ['bldg'], + }, + bldg: { + id: 'bldg', + type: 'building', + parentId: 'site_1', + position: [0, 0, 0], + rotation: [0, 0, 0], + children: ['level_0', 'level_roof'], + }, + level_0: { id: 'level_0', type: 'level', level: 0, parentId: 'bldg', children: [] }, + level_roof: { + id: 'level_roof', + type: 'level', + level: 1, + parentId: 'bldg', + children: ['roof_1'], + metadata: { role: 'roof' }, + }, + roof_1: { + id: 'roof_1', + type: 'roof', + parentId: 'level_roof', + position: [0, 0, 0], + rotation: 0, + children: ['rseg_1'], + }, + rseg_1: { + id: 'rseg_1', + type: 'roof-segment', + parentId: 'roof_1', + position: [0, 0, 0], + rotation: 0, + width: 10, + depth: 8, + overhang: 0.5, + }, + } + const drawing = buildSitePlanDrawing({ + nodes, + rootNodeIds: [], + collections: {}, + materials: {}, + } as unknown as SceneSnapshot) + const outlines = drawing.primitives.filter( + (g) => (g as { metadata?: { sitePlan?: string } }).metadata?.sitePlan === 'roof-outline', + ) + expect(outlines).toHaveLength(1) + }) +}) + function lot30(): [number, number][] { return [ [-15, -15], diff --git a/packages/editor/src/lib/level-selection.test.ts b/packages/editor/src/lib/level-selection.test.ts new file mode 100644 index 0000000000..e31c935eca --- /dev/null +++ b/packages/editor/src/lib/level-selection.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeId, + BuildingNode, + LevelNode, + RoofNode, + useScene, + WallNode, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { selectPlacedNode } from './level-selection' + +// A roof drawn over the ground floor's walls lands on the Roof level, which the editor hides while +// the ground floor is selected: selecting the roof there alone left it out of sight. +const BUILDING = 'building_select' as BuildingNode['id'] +const GROUND = 'level_ground' as LevelNode['id'] + +beforeEach(() => { + const ground = LevelNode.parse({ id: GROUND, parentId: BUILDING, level: 0, children: ['wall_a'] }) + const wall = WallNode.parse({ id: 'wall_a', parentId: GROUND, start: [0, 0], end: [4, 0] }) + const roofLevel = LevelNode.parse({ + id: 'level_roof', + parentId: BUILDING, + level: 1, + children: ['roof_main'], + metadata: { role: 'roof' }, + }) + const roof = RoofNode.parse({ id: 'roof_main', parentId: roofLevel.id }) + const building = BuildingNode.parse({ id: BUILDING, children: [GROUND, roofLevel.id] }) + useScene.setState({ + nodes: Object.fromEntries( + [building, ground, wall, roofLevel, roof].map((node) => [node.id, node as AnyNode]), + ), + rootNodeIds: [BUILDING], + } as never) + useViewer.getState().setSelection({ buildingId: BUILDING, levelId: GROUND }) +}) + +describe('selectPlacedNode', () => { + test('a node placed on another level is selected with its level', () => { + selectPlacedNode('roof_main' as AnyNodeId) + expect(useViewer.getState().selection).toMatchObject({ + buildingId: BUILDING, + levelId: 'level_roof', + selectedIds: ['roof_main'], + }) + }) + + test('a node placed on the level in view leaves the level as it is', () => { + selectPlacedNode('wall_a' as AnyNodeId) + expect(useViewer.getState().selection).toMatchObject({ + levelId: GROUND, + selectedIds: ['wall_a'], + }) + }) +}) diff --git a/packages/editor/src/lib/level-selection.ts b/packages/editor/src/lib/level-selection.ts index 94cdc585c6..1a3beb780c 100644 --- a/packages/editor/src/lib/level-selection.ts +++ b/packages/editor/src/lib/level-selection.ts @@ -1,5 +1,5 @@ import type { AnyNodeId, BuildingNode, LevelNode } from '@pascal-app/core' -import { useScene } from '@pascal-app/core' +import { findLevelAncestorId, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' function getAdjacentLevelIdForDeletion(levelId: AnyNodeId): LevelNode['id'] | null { @@ -29,3 +29,18 @@ export function deleteLevelWithFallbackSelection(levelId: AnyNodeId) { useViewer.getState().setSelection({ levelId: nextLevelId }) } } + +/** + * Selects a node a tool just made, on its own level when that is not the one in view: a roof drawn + * over a storey's walls lands on the level above, which the editor hides while the storey is + * selected, so selecting it there alone would leave it out of sight. + */ +export function selectPlacedNode(nodeId: AnyNodeId) { + const levelId = findLevelAncestorId(nodeId, useScene.getState().nodes) + const { selection, setSelection } = useViewer.getState() + setSelection( + levelId && levelId !== selection.levelId + ? { levelId: levelId as LevelNode['id'], selectedIds: [nodeId] } + : { selectedIds: [nodeId] }, + ) +} From 72ca3970a812dfe65f194ff3330d2ce6c66c5d83 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Mon, 12 Oct 2026 21:17:39 -0400 Subject: [PATCH 06/16] fix(editor): the site plan's house label does not count the roof level as a storey A bungalow under its Roof level read "2-STORY RESIDENCE". Co-Authored-By: Claude Opus 5.5 --- .../site-plan/build-site-plan-drawing.ts | 5 +- .../site-plan/site-annotations.test.ts | 59 +++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/packages/editor/src/lib/floorplan/site-plan/build-site-plan-drawing.ts b/packages/editor/src/lib/floorplan/site-plan/build-site-plan-drawing.ts index 3236f13cab..5a8f50d9a9 100644 --- a/packages/editor/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +++ b/packages/editor/src/lib/floorplan/site-plan/build-site-plan-drawing.ts @@ -4,6 +4,7 @@ import { type BuildingNode, envelopeFrontEdge, type FloorplanGeometry, + isRoofLevel, type LevelNode, type SceneSnapshot, type SiteNode, @@ -655,7 +656,9 @@ export function buildSitePlanDrawing(scene: SceneSnapshot): SitePlanDrawing { let storeys = 0 for (const childId of building?.children ?? []) { const child = scene.nodes[childId as AnyNodeId] as AnyNode | undefined - if (child?.type === 'level' && (child as LevelNode).level >= 0) storeys++ + // The roof level is not a storey: a bungalow under it is one storey. + if (child?.type === 'level' && (child as LevelNode).level >= 0 && !isRoofLevel(child)) + storeys++ } const cx = (footprintBounds.minX + footprintBounds.maxX) / 2 const cy = (footprintBounds.minY + footprintBounds.maxY) / 2 diff --git a/packages/editor/src/lib/floorplan/site-plan/site-annotations.test.ts b/packages/editor/src/lib/floorplan/site-plan/site-annotations.test.ts index 6820423ded..4c9312a64e 100644 --- a/packages/editor/src/lib/floorplan/site-plan/site-annotations.test.ts +++ b/packages/editor/src/lib/floorplan/site-plan/site-annotations.test.ts @@ -336,6 +336,65 @@ describe('the roof line', () => { }) }) +describe('the house label', () => { + test('the roof level is not a storey: a bungalow under it reads 1-STORY', () => { + const wall = (id: string, start: [number, number], end: [number, number]) => ({ + id, + type: 'wall', + parentId: 'level_0', + start, + end, + thickness: 0.2, + height: 2.8, + children: [], + metadata: {}, + }) + const nodes = { + site_1: { + id: 'site_1', + type: 'site', + polygon: { type: 'polygon', points: lot30() }, + children: ['bldg'], + }, + bldg: { + id: 'bldg', + type: 'building', + parentId: 'site_1', + position: [0, 0, 0], + rotation: [0, 0, 0], + children: ['level_0', 'level_roof'], + }, + level_0: { + id: 'level_0', + type: 'level', + level: 0, + parentId: 'bldg', + children: ['w1', 'w2', 'w3', 'w4'], + }, + level_roof: { + id: 'level_roof', + type: 'level', + level: 1, + parentId: 'bldg', + children: [], + metadata: { role: 'roof' }, + }, + w1: wall('w1', [-5, -4], [5, -4]), + w2: wall('w2', [5, -4], [5, 4]), + w3: wall('w3', [5, 4], [-5, 4]), + w4: wall('w4', [-5, 4], [-5, -4]), + } + const drawing = buildSitePlanDrawing({ + nodes, + rootNodeIds: [], + collections: {}, + materials: {}, + } as unknown as SceneSnapshot) + const texts = drawing.primitives.map((g) => (g as { text?: string }).text ?? '') + expect(texts.join('\n')).toContain('1-STORY RESIDENCE') + }) +}) + function lot30(): [number, number][] { return [ [-15, -15], From c778645296deda64bac9646312da067dd0aad9b5 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Mon, 12 Oct 2026 23:18:51 -0400 Subject: [PATCH 07/16] chore: comments say why, without bench runs, ticket numbers or dates The ported comments pointed at things this repository does not hold: bench learnings by number (L41, L65, L68), bench runs and houses ("run 4", "the 290's Bed 4", "Hawkesbury", "Victor run 5") and who asked when ("the owner, 8 October"). Each keeps its reason, said plainly. A test's unused map goes. Co-Authored-By: Claude Opus 5.5 --- .../__fixtures__/build-room-cases.ts | 4 ++-- .../__fixtures__/corner-window-cases.ts | 8 ++++---- .../__fixtures__/merge-windows-cases.ts | 14 +++++++------- .../__fixtures__/roof-cases.ts | 5 ++--- .../core/src/agent-operations/corner-window.ts | 8 ++++---- .../agent-operations/level-height-fill.test.ts | 4 ++-- .../src/agent-operations/level-height-fill.ts | 12 ++++++------ .../src/agent-operations/roof-footprint.ts | 13 ++++++------- packages/core/src/agent-tools/roofs.ts | 3 +-- .../core/src/building/corner-window.test.ts | 16 ++++++++-------- packages/core/src/building/corner-window.ts | 6 +++--- .../core/src/building/merge-windows.test.ts | 5 ++--- packages/core/src/building/merge-windows.ts | 5 ++--- .../structure/create-zone-walled-room.test.ts | 6 +++--- .../core/src/commands/structure/create-zone.ts | 4 ++-- packages/core/src/lib/corner-pair.ts | 4 ++-- packages/core/src/lib/corner-snap.test.ts | 18 +++++++++--------- packages/core/src/lib/corner-snap.ts | 11 +++++------ packages/core/src/lib/snap-ease.ts | 6 +++--- packages/core/src/schema/nodes/window.ts | 4 ++-- packages/core/src/services/roof-level.test.ts | 6 +++--- .../src/store/use-corner-snap-hint.test.ts | 4 ++-- .../core/src/store/use-corner-snap-hint.ts | 8 ++++---- packages/mcp/src/tools/apply-patch.test.ts | 4 ++-- packages/mcp/src/tools/level-height-guard.ts | 6 +++--- packages/nodes/src/window/corner-cue.ts | 8 ++++---- packages/nodes/src/window/move-tool.tsx | 6 +++--- packages/nodes/src/window/panel.tsx | 4 ++-- packages/nodes/src/window/placement.ts | 4 ++-- packages/nodes/src/window/tool.tsx | 6 +++--- .../src/systems/window/corner-window.test.ts | 8 ++++---- .../src/systems/window/window-system.tsx | 8 ++++---- 32 files changed, 111 insertions(+), 117 deletions(-) diff --git a/packages/core/src/agent-operations/__fixtures__/build-room-cases.ts b/packages/core/src/agent-operations/__fixtures__/build-room-cases.ts index 3853ec6517..21d051eeb5 100644 --- a/packages/core/src/agent-operations/__fixtures__/build-room-cases.ts +++ b/packages/core/src/agent-operations/__fixtures__/build-room-cases.ts @@ -613,8 +613,8 @@ export const CREATE_ROOM_CASES: AgentToolCase[] = [ }, }, }, - // The owner (7 October): a room a person named is never renamed silently, and never twinned: the - // refusal names it, and the agent either passes rename: true or asks the person. + // A room a person named is never renamed silently, and never twinned: the refusal names it, and + // the agent either passes rename: true or asks the person. { name: 'a room a person named is refused with a choice, never twinned or renamed silently', tool: 'create_room', diff --git a/packages/core/src/agent-operations/__fixtures__/corner-window-cases.ts b/packages/core/src/agent-operations/__fixtures__/corner-window-cases.ts index ec7e4d7c57..62b3390b29 100644 --- a/packages/core/src/agent-operations/__fixtures__/corner-window-cases.ts +++ b/packages/core/src/agent-operations/__fixtures__/corner-window-cases.ts @@ -2,10 +2,10 @@ import { BuildingNode, LevelNode, WallNode, type WindowNode } from '../../schema import type { AgentToolCase, SceneGraph } from './cases' /** - * `add_corner_window` (L65): the 290's Bed 4 window wraps the corner of its bay; run 4 built one - * window on one face and recorded it built. What goes wrong, written first: the two sides not - * joined or not reaching the corner; a bay's 135° refused or squared; a side wider than its wall, - * a point no two walls meet at, or a corner too shallow to be one, built anyway. + * `add_corner_window`: a window that wraps the corner of a bay, built as one window on one face and + * recorded built until this tool. What goes wrong, written first: the two sides not joined or not + * reaching the corner; a bay's 135° refused or squared; a side wider than its wall, a point no two + * walls meet at, or a corner too shallow to be one, built anyway. */ /** Wall A runs into the corner at the origin; wall B leaves it at `degrees` from A. */ diff --git a/packages/core/src/agent-operations/__fixtures__/merge-windows-cases.ts b/packages/core/src/agent-operations/__fixtures__/merge-windows-cases.ts index ecd57a504a..eb03bc2d56 100644 --- a/packages/core/src/agent-operations/__fixtures__/merge-windows-cases.ts +++ b/packages/core/src/agent-operations/__fixtures__/merge-windows-cases.ts @@ -2,13 +2,13 @@ import { BuildingNode, LevelNode, WallNode, WindowNode } from '../../schema' import type { AgentToolCase, SceneGraph } from './cases' /** - * `merge_windows` (the owner, 8 October): two windows that already exist become one. On two walls - * meeting at a corner, each reaching it within 0.30 m, they become one corner window (adopted, slid - * to the corner, the glass fused, one sill); side by side on one wall with at most 0.30 m between - * them and nothing between, one window spanning both. What goes wrong, written first: a window - * recreated instead of adopted; the sides left on two sills; the second window's type or style - * winning over the first's; a door, a window or an item between them swallowed; two windows that - * are nowhere near each other joined anyway; a window already joined to another stolen. + * `merge_windows`: two windows that already exist become one. On two walls meeting at a corner, + * each reaching it within 0.30 m, they become one corner window (adopted, slid to the corner, the + * glass fused, one sill); side by side on one wall with at most 0.30 m between them and nothing + * between, one window spanning both. What goes wrong, written first: a window recreated instead of + * adopted; the sides left on two sills; the second window's type or style winning over the first's; + * a door, a window or an item between them swallowed; two windows that are nowhere near each other + * joined anyway; a window already joined to another stolen. */ type WindowSpec = { diff --git a/packages/core/src/agent-operations/__fixtures__/roof-cases.ts b/packages/core/src/agent-operations/__fixtures__/roof-cases.ts index 9ce488332b..7f92eabca9 100644 --- a/packages/core/src/agent-operations/__fixtures__/roof-cases.ts +++ b/packages/core/src/agent-operations/__fixtures__/roof-cases.ts @@ -12,9 +12,8 @@ import type { AgentToolCase, SceneGraph } from './cases' /** * `create_roof`, one tool where the chat and the MCP had one each with two level rules: the roof - * over a building's top storey goes on its roof level, the level directly above (the owner: a - * roof made on the ground floor's level blocked the exploded view), and stands on the storey's - * wall tops. + * over a building's top storey goes on its roof level, the level directly above (a roof made on the + * ground floor's level blocked the exploded view), and stands on the storey's wall tops. * * A building of 2.5 m storeys, each an 8 × 6 m room walled round; the top one is `walls` tall. */ diff --git a/packages/core/src/agent-operations/corner-window.ts b/packages/core/src/agent-operations/corner-window.ts index d0e4d02a08..72a402ac7d 100644 --- a/packages/core/src/agent-operations/corner-window.ts +++ b/packages/core/src/agent-operations/corner-window.ts @@ -7,7 +7,7 @@ import { registerSceneCheck, type SceneIssue } from './verify-scene' type AddCornerWindowInput = Parameters[1] -/** `add_corner_window` (L65): one window on each of the two walls meeting at a corner, joined. */ +/** `add_corner_window`: one window on each of the two walls meeting at a corner, joined. */ export const addCornerWindow: AgentOperation = (nodes, input) => { const { windows, wallIds, angle } = planCornerWindow(nodes, input) const changes: SceneChanges = { @@ -30,9 +30,9 @@ export const addCornerWindow: AgentOperation = (nodes, inp const round = (value: number) => Math.round(value * 100) / 100 /** - * Corner windows, checked (L65): two windows running to the same corner unjoined (run 4 built one - * face of the 290's Bed 4 corner window and did not see the wrap), a pair whose sides drifted - * apart, a window naming a partner that is gone. + * Corner windows, checked: two windows running to the same corner unjoined (one face of a corner + * window built, the wrap missed), a pair whose sides drifted apart, a window naming a partner that + * is gone. */ export function cornerWindowIssues(nodes: Parameters[0]): SceneIssue[] { const issues: SceneIssue[] = [] diff --git a/packages/core/src/agent-operations/level-height-fill.test.ts b/packages/core/src/agent-operations/level-height-fill.test.ts index c0864a0f0a..25ac1f6aa5 100644 --- a/packages/core/src/agent-operations/level-height-fill.test.ts +++ b/packages/core/src/agent-operations/level-height-fill.test.ts @@ -5,8 +5,8 @@ import { DEFAULT_LEVEL_HEIGHT } from '../services/level-height' import { migrateVerticalSceneNodes } from '../utils/scene-migrations' import { fillLevelHeights, type LevelHeightWrite } from './level-height-fill' -// L68: a level written without a height is legacy to the editor's load, which derives its storey -// plane; the session that wrote it read 2.5 m. A write gives it the height the load would derive. +// A level written without a height is legacy to the editor's load, which derives its storey plane; +// the session that wrote it read 2.5 m. A write gives it the height the load would derive. const building = BuildingNode.parse({ id: 'building_main' }) const scene = { [building.id]: building } as Record const heightless = (id: string) => { diff --git a/packages/core/src/agent-operations/level-height-fill.ts b/packages/core/src/agent-operations/level-height-fill.ts index 969431bd1f..4f663b713d 100644 --- a/packages/core/src/agent-operations/level-height-fill.ts +++ b/packages/core/src/agent-operations/level-height-fill.ts @@ -10,12 +10,12 @@ export type LevelHeightWrite = const round = (value: number) => Math.round(value * 100) / 100 /** - * A level written without a height reads as legacy data: the editor's load derives its storey - * plane from what it holds and rewrites its walls, stairs and ceilings to match, while a session - * that wrote it reads 2.5 m (L68). A write that creates a level without one, or clears it, gets - * the height the editor would derive, from what the level holds once the whole write lands (an - * empty level: the default storey height), so every reader agrees. Fills the operations in place - * and returns a note per level filled, for the write's answer. + * A level written without a height reads as legacy data: the editor's load derives its storey plane + * from what it holds and rewrites its walls, stairs and ceilings to match, while a session that + * wrote it reads 2.5 m. A write that creates a level without one, or clears it, gets the height the + * editor would derive, from what the level holds once the whole write lands (an empty level: the + * default storey height), so every reader agrees. Fills the operations in place and returns a note + * per level filled, for the write's answer. */ export function fillLevelHeights( ops: LevelHeightWrite[], diff --git a/packages/core/src/agent-operations/roof-footprint.ts b/packages/core/src/agent-operations/roof-footprint.ts index f8a3e1a798..ce2f3bd9b2 100644 --- a/packages/core/src/agent-operations/roof-footprint.ts +++ b/packages/core/src/agent-operations/roof-footprint.ts @@ -1,11 +1,10 @@ // The rectangles a roof is built from, one roof segment each: the footprint's wings, each as long // as it runs inside the footprint, so they overlap where they meet. The roof renders their union, // so a wing's hips meet the body's slopes in valleys; wings cut where they meet put a hip end each -// side of the cut, a notch along the joint (L41, Hawkesbury's hip-roof facade). -// Coordinates scaled from a plan carry float noise that sliced the footprint into slivers (468 hip -// segments on the Victor's top floor, which froze the page), so the footprint is snapped first, -// balcony-thin strips join their larger neighbour, and a pathological footprint falls back to its -// bounding box. +// side of the cut, a notch along the joint. Coordinates scaled from a plan carry float noise that +// sliced the footprint into slivers (468 hip segments on one plan's top floor, which froze the +// page), so the footprint is snapped first, balcony-thin strips join their larger neighbour, and a +// pathological footprint falls back to its bounding box. type Pt = [number, number] export type RoofRectangle = { minX: number; maxX: number; minZ: number; maxZ: number } @@ -264,8 +263,8 @@ function mergeWings( /** * The frame a roof is swept in: the footprint's own axes, from its longest edge. Swept along the - * world axes, a building turned 20° got a staircase of segments (Victor run 5). The rotation - * convention is a roof node's: local (x, z) → (x c + z s, -x s + z c). + * world axes, a building turned 20° got a staircase of segments. The rotation convention is a roof + * node's: local (x, z) → (x c + z s, -x s + z c). */ export function buildingFrame(polygons: readonly (readonly Pt[])[]) { let longest = { length: 0, dx: 1, dz: 0 } diff --git a/packages/core/src/agent-tools/roofs.ts b/packages/core/src/agent-tools/roofs.ts index fc50674396..fb67281df6 100644 --- a/packages/core/src/agent-tools/roofs.ts +++ b/packages/core/src/agent-tools/roofs.ts @@ -45,8 +45,7 @@ export const createRoofTool = { .optional() .describe("The rectangle's centre [x, z] in metres (default [0, 0])."), materialPreset: z.string().optional().describe('The roof finish, a library material.'), - // Hawkesbury's flat roofs wear a black fascia about 0.3 m tall, which only apply_patch could - // draw (2026-10-03). + // A flat roof's fascia band (often about 0.3 m tall), which only apply_patch could draw before. fasciaHeight: measurement('length', 'm', { positive: true, description: diff --git a/packages/core/src/building/corner-window.test.ts b/packages/core/src/building/corner-window.test.ts index c0305ef5eb..17790c1366 100644 --- a/packages/core/src/building/corner-window.test.ts +++ b/packages/core/src/building/corner-window.test.ts @@ -12,13 +12,13 @@ import { } from './corner-window' /** - * L65: the 290's Bed 4 window wraps the corner of its bay; Pascal had no corner window (a window - * ends at its wall and the corner stays solid). A corner window is two windows, one per wall, both - * running to the corner and joined, the glass fused at the corner (or meeting at a thin post), at - * any angle the walls make. What goes wrong, written first: a window stopping short of the corner; - * the two sides out of step (heights, sills); one wall's side placed on the wrong wall or end; a - * square-only answer at a bay's 135° or an acute 60°; a degenerate corner accepted; unwrapping - * deleting a window. + * A window that wraps the corner of a bay: Pascal had no corner window (a window ends at its wall + * and the corner stays solid). A corner window is two windows, one per wall, both running to the + * corner and joined, the glass fused at the corner (or meeting at a thin post), at any angle the + * walls make. What goes wrong, written first: a window stopping short of the corner; the two sides + * out of step (heights, sills); one wall's side placed on the wrong wall or end; a square-only + * answer at a bay's 135° or an acute 60°; a degenerate corner accepted; unwrapping deleting a + * window. */ type Nodes = Record @@ -165,7 +165,7 @@ describe('by hand: wrap, keep in step, unwrap', () => { }) }) - // The owner (8 October): a window a few cm short of the corner showed no Corner section at all. + // A window a few cm short of the corner still has a Corner section to wrap it. const onWall = (id: string, wall: string, x: number, width = 1.2) => WindowNode.parse({ id, diff --git a/packages/core/src/building/corner-window.ts b/packages/core/src/building/corner-window.ts index 3b92e95165..862c20e1ea 100644 --- a/packages/core/src/building/corner-window.ts +++ b/packages/core/src/building/corner-window.ts @@ -40,9 +40,9 @@ export type CornerWindowInput = { } /** - * A corner window (L65): one window on each of the two walls meeting at `corner`, each running to - * it, joined, at any angle the walls make. Each side is placed by add_window's rules, and the two - * keep one sill: with none given, both are centred on the lower of the two walls. + * A corner window: one window on each of the two walls meeting at `corner`, each running to it, + * joined, at any angle the walls make. Each side is placed by add_window's rules, and the two keep + * one sill: with none given, both are centred on the lower of the two walls. */ export function planCornerWindow(nodes: Nodes, input: CornerWindowInput) { const point = input.corner diff --git a/packages/core/src/building/merge-windows.test.ts b/packages/core/src/building/merge-windows.test.ts index 6e8c8aada3..40ea67aa69 100644 --- a/packages/core/src/building/merge-windows.test.ts +++ b/packages/core/src/building/merge-windows.test.ts @@ -2,10 +2,9 @@ import { describe, expect, test } from 'bun:test' import { type AnyNode, LevelNode, WallNode, WindowNode } from '../schema' import { MERGE_GAP_M, mergeWindows, windowMergeOffer } from './merge-windows' -// The Merge windows button (the owner, 8 October): offered when exactly two windows are selected, -// disabled with the reason when they cannot join, and silent for any other selection. +// The Merge windows button: offered when exactly two windows are selected, disabled with the +// reason when they cannot join, and silent for any other selection. -const wall = new Map() const nodes = (extra: AnyNode[] = []) => { const a = WallNode.parse({ id: 'wall_m', diff --git a/packages/core/src/building/merge-windows.ts b/packages/core/src/building/merge-windows.ts index 4b8812e144..1ac41e1411 100644 --- a/packages/core/src/building/merge-windows.ts +++ b/packages/core/src/building/merge-windows.ts @@ -13,9 +13,8 @@ import type { AnyNode, AnyNodeId, WallNode, WindowNode } from '../schema' import { findWallChildOverlap, resolveWallOpeningCeiling } from './wall-openings' /** - * Two windows that exist become one (the owner, 8 October): the one operation behind the editor's - * Merge windows button, Wrap the corner when a window already waits at the corner, and the agents' - * `merge_windows`. + * Two windows that exist become one: the one operation behind the editor's Merge windows button, + * Wrap the corner when a window already waits at the corner, and the agents' `merge_windows`. * * On two walls that meet at a corner, each reaching it within `CORNER_REACH_M`, they become one * corner window: both adopted, each slid to the corner keeping its width, the glass fused, one sill. diff --git a/packages/core/src/commands/structure/create-zone-walled-room.test.ts b/packages/core/src/commands/structure/create-zone-walled-room.test.ts index 2b56dfda4d..43a44b1e39 100644 --- a/packages/core/src/commands/structure/create-zone-walled-room.test.ts +++ b/packages/core/src/commands/structure/create-zone-walled-room.test.ts @@ -10,9 +10,9 @@ import { } from '../../schema' import { createZone } from './create-zone' -// Walls first, then a room drawn over them (run 6): the editor's own draw (a terrace over a walled -// courtyard) and an agent's create_room both go through createZone. What it does stays what a person -// meets by hand; the agent's refusal over a room a person named lives in create_room, not here. +// Walls first, then a room drawn over them: the editor's own draw (a terrace over a walled +// courtyard) and an agent's create_room both go through createZone. What it does stays what a +// person meets by hand; the agent's refusal over a room a person named lives in create_room. const SQUARE: [number, number][] = [ [0, 0], diff --git a/packages/core/src/commands/structure/create-zone.ts b/packages/core/src/commands/structure/create-zone.ts index 89d83624e2..a85eb3fa54 100644 --- a/packages/core/src/commands/structure/create-zone.ts +++ b/packages/core/src/commands/structure/create-zone.ts @@ -58,8 +58,8 @@ export function outdoorRoomConflicts(nodes: StructureNodes, levelId: string, pol /** * The room walls already make where a room is drawn, to name rather than stack a second zone on - * (run 6: walls first, then create_room, gave every room a "Room N" twin). Only when every edge of - * the polygon runs along a wall, so nothing is built and the rooms stand as they are; the room the + * (walls first, then create_room, gave every room a "Room N" twin). Only when every edge of the + * polygon runs along a wall, so nothing is built and the rooms stand as they are; the room the * polygon fits by the reconciler's own adoption rule, and only while it keeps the name the * reconciler gave it. */ diff --git a/packages/core/src/lib/corner-pair.ts b/packages/core/src/lib/corner-pair.ts index 29b89367ed..2609d0f25c 100644 --- a/packages/core/src/lib/corner-pair.ts +++ b/packages/core/src/lib/corner-pair.ts @@ -1,8 +1,8 @@ import type { AnyNode, WallNode, WindowNode } from '../schema' /** - * Corner windows (L65): a window joined at its wall's end to a window on the wall that meets it - * there. The lookups the viewer and the editor read; planning one lives in building/corner-window. + * Corner windows: a window joined at its wall's end to a window on the wall that meets it there. + * The lookups the viewer and the editor read; planning one lives in building/corner-window. */ type Nodes = Readonly> diff --git a/packages/core/src/lib/corner-snap.test.ts b/packages/core/src/lib/corner-snap.test.ts index 14fc012c69..81435c6026 100644 --- a/packages/core/src/lib/corner-snap.test.ts +++ b/packages/core/src/lib/corner-snap.test.ts @@ -2,11 +2,11 @@ import { describe, expect, test } from 'bun:test' import { type AnyNode, LevelNode, WallNode, WindowNode } from '../schema' import { CORNER_SNAP_EXIT_M, cornerBlock, cornerSnap } from './corner-snap' -// The owner (8 October): placing or dragging a window near a corner told you nothing about joining -// the window on the other wall. The ghost snaps its edge to the corner inside a zone, the window -// waiting there is found for the highlight, and the zone has hysteresis so the hint does not -// flicker at its edge. Written first: no snap inside 0.30 m; snapping where no corner is; the zone -// flickering at its threshold; the window waiting on the other wall not found, or a pair's taken. +// Placing or dragging a window near a corner used to say nothing about joining the window on the +// other wall. The ghost snaps its edge to the corner inside a zone, the window waiting there is +// found for the highlight, and the zone has hysteresis so the hint does not flicker at its edge. +// Written first: no snap inside 0.30 m; snapping where no corner is; the zone flickering at its +// threshold; the window waiting on the other wall not found, or a pair's taken. const LEVEL = 'level_s' @@ -152,10 +152,10 @@ describe('the window waiting at the other wall’s corner', () => { }) }) -// The owner could not find "Wrap the corner" (8 October): a lone wall, a T-junction, a curved or -// curtain wall, an angle out of range, or no room for the match show nothing, so the hint says why -// where a window is placed or dragged within the zone. Written first: a reason where there is none, -// no reason where the corner would join, and the zone's hysteresis leaving the reason flickering. +// "Wrap the corner" was hard to find: a lone wall, a T-junction, a curved or curtain wall, an angle +// out of range, or no room for the match show nothing, so the hint says why where a window is +// placed or dragged within the zone. Written first: a reason where there is none, no reason where +// the corner would join, and the zone's hysteresis leaving the reason flickering. describe('why a wall end is not a corner', () => { const input = { wallId: 'wall_a', centreX: centreAt(0.1), width: 1.2 } diff --git a/packages/core/src/lib/corner-snap.ts b/packages/core/src/lib/corner-snap.ts index 7fdc76d41e..4d4a8a58dd 100644 --- a/packages/core/src/lib/corner-snap.ts +++ b/packages/core/src/lib/corner-snap.ts @@ -14,9 +14,9 @@ import { } from './corner-pair' /** - * Placing or dragging a window near a corner (the owner, 8 October): inside the corner zone the - * window snaps its edge to the corner and the window waiting on the other wall, if any, is found, so - * the tool can highlight it and say the two will join. Pure: the tools read the answer. + * Placing or dragging a window near a corner: inside the corner zone the window snaps its edge to + * the corner and the window waiting on the other wall, if any, is found, so the tool can highlight + * it and say the two will join. Pure: the tools read the answer. */ type Nodes = Readonly> @@ -39,9 +39,8 @@ export type CornerSnap = { } /** - * Why a wall end in the zone is not a corner window's: the owner could not find Wrap the corner - * where a lone wall, a T-junction, a curved or curtain wall, a flat angle or a full wall gave - * nothing at all, so the hint says it. + * Why a wall end in the zone is not a corner window's: a lone wall, a T-junction, a curved or + * curtain wall, a flat angle or a full wall would otherwise give no sign, so the hint says it. */ export type CornerBlockReason = | 'free_end' diff --git a/packages/core/src/lib/snap-ease.ts b/packages/core/src/lib/snap-ease.ts index fd92f42797..7da82dd1a7 100644 --- a/packages/core/src/lib/snap-ease.ts +++ b/packages/core/src/lib/snap-ease.ts @@ -1,7 +1,7 @@ /** - * The ghost snapping to a corner (the owner, 8 October): a short eased move, never a jump or a - * bounce. Under 200 ms and easing out, so it feels like the window was pulled in; it keeps following - * the cursor while it eases, and does nothing at all under reduced motion. + * The ghost snapping to a corner: a short eased move, never a jump or a bounce. Under 200 ms and + * easing out, so it feels like the window was pulled in; it keeps following the cursor while it + * eases, and does nothing at all under reduced motion. */ /** How long the ghost takes to move into, or out of, a snap (ms). */ diff --git a/packages/core/src/schema/nodes/window.ts b/packages/core/src/schema/nodes/window.ts index 0c45c868f1..e9b5f5913f 100644 --- a/packages/core/src/schema/nodes/window.ts +++ b/packages/core/src/schema/nodes/window.ts @@ -99,8 +99,8 @@ export const WindowNode = BaseNode.extend({ columnDividerThickness: z.number().default(0.03), rowDividerThickness: z.number().default(0.03), - // A corner window (L65): this window runs to its wall's `end` and is joined there to `partnerId` - // on the other wall, the glass fused at the corner (`none`) or meeting at a thin post. + // A corner window: this window runs to its wall's `end` and is joined there to `partnerId` on the + // other wall, the glass fused at the corner (`none`) or meeting at a thin post. corner: z .object({ end: z.enum(['start', 'end']), diff --git a/packages/core/src/services/roof-level.test.ts b/packages/core/src/services/roof-level.test.ts index 1dbbf5fd88..8788f3e940 100644 --- a/packages/core/src/services/roof-level.test.ts +++ b/packages/core/src/services/roof-level.test.ts @@ -2,9 +2,9 @@ import { describe, expect, test } from 'bun:test' import { type AnyNode, BuildingNode, LevelNode, RoofNode, WallNode, ZoneNode } from '../schema' import { isRoofLevel, planRoofLevel, roofsOver, storeyInsertIndex } from './roof-level' -// One rule for every roof writer (the owner, 12 October: a roof made on the ground floor's level -// blocks the exploded view): a roof lives on the level directly above the storey whose walls carry -// it, and over the top storey that is the building's roof level. +// One rule for every roof writer (a roof made on the ground floor's level blocks the exploded +// view): a roof lives on the level directly above the storey whose walls carry it, and over the top +// storey that is the building's roof level. type Content = 'walls' | 'roof' | 'empty' diff --git a/packages/core/src/store/use-corner-snap-hint.test.ts b/packages/core/src/store/use-corner-snap-hint.test.ts index 34c8bf472f..021feb70d7 100644 --- a/packages/core/src/store/use-corner-snap-hint.test.ts +++ b/packages/core/src/store/use-corner-snap-hint.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from 'bun:test' import { CORNER_BLOCK_NOTES, cornerHintRows } from './use-corner-snap-hint' -// The HUD near a wall end (the owner, 8 October): what a click does where a window can join, and -// why not where it cannot, in one wording for the placement HUD and the move HUD. +// The HUD near a wall end: what a click does where a window can join, and why not where it cannot, +// in one wording for the placement HUD and the move HUD. describe('cornerHintRows', () => { test('says nothing away from a wall end', () => { expect(cornerHintRows(null)).toEqual([]) diff --git a/packages/core/src/store/use-corner-snap-hint.ts b/packages/core/src/store/use-corner-snap-hint.ts index f9143aac7d..3d3b4dbc47 100644 --- a/packages/core/src/store/use-corner-snap-hint.ts +++ b/packages/core/src/store/use-corner-snap-hint.ts @@ -2,10 +2,10 @@ import { create } from 'zustand' import type { CornerBlockReason } from '../lib/corner-snap' /** - * What the window being placed or dragged near a wall end says (the owner, 8 October): `join` when - * a window already waits on the other wall, `new` when one would be created, the reason when the - * end is not a corner a window can wrap, null away from a wall end. The tools write it; the HUD - * reads it, so the hint is there only inside the zone. + * What the window being placed or dragged near a wall end says: `join` when a window already waits + * on the other wall, `new` when one would be created, the reason when the end is not a corner a + * window can wrap, null away from a wall end. The tools write it; the HUD reads it, so the hint is + * there only inside the zone. */ export type CornerSnapHint = 'join' | 'new' | CornerBlockReason | null diff --git a/packages/mcp/src/tools/apply-patch.test.ts b/packages/mcp/src/tools/apply-patch.test.ts index 5fba7b2133..2846c7d939 100644 --- a/packages/mcp/src/tools/apply-patch.test.ts +++ b/packages/mcp/src/tools/apply-patch.test.ts @@ -90,8 +90,8 @@ describe('apply_patch', () => { expect(bridge.getNode(wall.id)).not.toHaveProperty('materialPreset') }) - // L68: a level written without a height is legacy to the editor's load, which derives its storey - // from what it holds; the session read 2.5 m. The write now gets the derived height, said in notes. + // A level written without a height is legacy to the editor's load, which derives its storey from + // what it holds; the session read 2.5 m. The write now gets the derived height, said in notes. test('a level created without a height gets the height its walls give, and says so', async () => { const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building')! const { height: _height, ...level } = LevelNode.parse({ parentId: building.id, level: 1 }) diff --git a/packages/mcp/src/tools/level-height-guard.ts b/packages/mcp/src/tools/level-height-guard.ts index 5dd4b60fdd..d73863135e 100644 --- a/packages/mcp/src/tools/level-height-guard.ts +++ b/packages/mcp/src/tools/level-height-guard.ts @@ -4,9 +4,9 @@ import type { Patch } from '../bridge/scene-bridge' import { registerPatchGuard } from './patch-guards' /** - * A level is written with a height (L68): a create without one, or an update clearing it, gets the - * height the editor's load would derive, said in the answer's notes. After the honest-update check, - * which turns a `null` into a cleared field. + * A level is written with a height: a create without one, or an update clearing it, gets the height + * the editor's load would derive, said in the answer's notes. After the honest-update check, which + * turns a `null` into a cleared field. */ function levelHeights(patches: Patch[], scene: Readonly>) { return fillLevelHeights(patches as unknown as LevelHeightWrite[], scene) diff --git a/packages/nodes/src/window/corner-cue.ts b/packages/nodes/src/window/corner-cue.ts index a3771fd316..12629eb49a 100644 --- a/packages/nodes/src/window/corner-cue.ts +++ b/packages/nodes/src/window/corner-cue.ts @@ -1,10 +1,10 @@ import { type CornerBlock, type CornerSnap, useCornerSnapHint } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -// What a window placed or dragged near a corner shows (the owner, 8 October): the HUD hint says -// what a click will do, and the window already waiting on the other wall is outlined in the -// editor's hover colour, so the join is visible before it happens. Where a wall end is not a -// corner a window can wrap, the HUD says why instead of showing nothing. +// What a window placed or dragged near a corner shows: the HUD hint says what a click will do, and +// the window already waiting on the other wall is outlined in the editor's hover colour, so the +// join is visible before it happens. Where a wall end is not a corner a window can wrap, the HUD +// says why instead of showing nothing. let outlined: string | null = null diff --git a/packages/nodes/src/window/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx index 78f9260e69..dad15edd37 100644 --- a/packages/nodes/src/window/move-tool.tsx +++ b/packages/nodes/src/window/move-tool.tsx @@ -266,9 +266,9 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode // The wall the window was grabbed from. Nulled the first time the anchor // seeds on any other host: the grab offset is then forgotten for good. let grabWallId: string | null = movingWindowNode.parentId - // Dragging near a corner (the owner, 8 October): the same zone as placing: the window snaps its - // edge to the corner, the window waiting on the other wall is outlined, and on release they - // join. Alt drops it plain. + // Dragging near a corner, the same zone as placing: the window snaps its edge to the corner, + // the window waiting on the other wall is outlined, and on release they join. Alt drops it + // plain. let cornerHeld: CornerSnap['end'] | null = null const cornerEaser = createSnapEaser() let cornerEaseFrame = 0 diff --git a/packages/nodes/src/window/panel.tsx b/packages/nodes/src/window/panel.tsx index c0b67cfbf6..53c7a064e2 100644 --- a/packages/nodes/src/window/panel.tsx +++ b/packages/nodes/src/window/panel.tsx @@ -84,7 +84,7 @@ function normalizeWindowCornerRadii( return next.map((radius) => radius * scale) as [number, number, number, number] } -/** A change to one side of a corner window that both share, made on its partner too (L65). */ +/** A change to one side of a corner window that both share, made on its partner too. */ function syncCornerPartner(windowId: string, updates: Partial) { const scene = useScene.getState() const partnerUpdate = cornerPartnerUpdates(scene.nodes, windowId, updates) @@ -142,7 +142,7 @@ export default function WindowPanel() { [selectedId], ) - // A corner window's sides (L65): its partner, and whether this window can wrap its corner. + // A corner window's sides: its partner, and whether this window can wrap its corner. const cornerState = useScene((s) => { const live = selectedId ? s.nodes[selectedId as AnyNodeId] : undefined if (live?.type !== 'window') return 'none' diff --git a/packages/nodes/src/window/placement.ts b/packages/nodes/src/window/placement.ts index 960172ac59..8bed34e697 100644 --- a/packages/nodes/src/window/placement.ts +++ b/packages/nodes/src/window/placement.ts @@ -104,8 +104,8 @@ export const WINDOW_PLACEMENT_HINTS: ToolHint[] = [ ] /** - * What a click does while the ghost is inside a corner zone (the owner, 8 October), or why it is not - * a corner: shown only there, so the hint appears when the window can join and goes when it leaves. + * What a click does while the ghost is inside a corner zone, or why it is not a corner: shown only + * there, so the hint appears when the window can join and goes when it leaves. */ const HINTS_FOR: CornerSnapHint[] = [ 'join', diff --git a/packages/nodes/src/window/tool.tsx b/packages/nodes/src/window/tool.tsx index c7c9b0a9a7..e940191095 100644 --- a/packages/nodes/src/window/tool.tsx +++ b/packages/nodes/src/window/tool.tsx @@ -223,9 +223,9 @@ const WindowTool: React.FC = () => { // to the last wall hover so the flip shows live before commit. let sideFlip = false let lastWallEvent: WallEvent | null = null - // Placing near a corner (the owner, 8 October): inside the corner zone the ghost snaps its edge - // to the corner, the window waiting on the other wall is outlined, and the HUD says what a click - // does. Alt places the window plain. + // Placing near a corner: inside the corner zone the ghost snaps its edge to the corner, the + // window waiting on the other wall is outlined, and the HUD says what a click does. Alt places + // the window plain. let cornerHeld: CornerSnap['end'] | null = null let altDown = false const cornerEaser = createSnapEaser() diff --git a/packages/viewer/src/systems/window/corner-window.test.ts b/packages/viewer/src/systems/window/corner-window.test.ts index abefeed3b9..98e701e1a7 100644 --- a/packages/viewer/src/systems/window/corner-window.test.ts +++ b/packages/viewer/src/systems/window/corner-window.test.ts @@ -15,10 +15,10 @@ import { generateExtrudedWall } from '../wall/wall-system' import { buildWindowPreviewMesh } from './window-system' /** - * L65: a corner window opens the corner between its two walls, the glass fused there (`none`) or - * meeting at the corner jambs (`post`), at any angle. What goes wrong, written first: the walls' - * corner left solid (a stub post the user did not ask for); a fused corner still drawing its jamb; - * the glass stopping short of the corner; a bay's 135° or an acute 60° cut as if square. + * A corner window opens the corner between its two walls, the glass fused there (`none`) or meeting + * at the corner jambs (`post`), at any angle. What goes wrong, written first: the walls' corner + * left solid (a stub post the user did not ask for); a fused corner still drawing its jamb; the + * glass stopping short of the corner; a bay's 135° or an acute 60° cut as if square. */ const LEVEL = 'level_cw' diff --git a/packages/viewer/src/systems/window/window-system.tsx b/packages/viewer/src/systems/window/window-system.tsx index 5841e949bd..8e247344b3 100644 --- a/packages/viewer/src/systems/window/window-system.tsx +++ b/packages/viewer/src/systems/window/window-system.tsx @@ -3380,9 +3380,9 @@ function addShapedLouveredWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { } /** - * The side of a corner window fused at the corner (L65), in the window's own frame: 1 its +x side, - * -1 its -x side, 0 none. A window facing its wall's back is turned half round, so its +x then - * points to the wall's start. + * The side of a corner window fused at the corner, in the window's own frame: 1 its +x side, -1 its + * -x side, 0 none. A window facing its wall's back is turned half round, so its +x then points to + * the wall's start. */ function fusedCornerSide(node: WindowNode, rotationY: number): -1 | 0 | 1 { const pair = cornerPair(useScene.getState().nodes, node) @@ -3517,7 +3517,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh): boolean { return true } - // A corner window fused at the corner (L65): no jamb on that side, the glass runs to the corner. + // A corner window fused at the corner: no jamb on that side, the glass runs to the corner. const fused = fusedCornerSide(node, placement.rotation[1]) const innerW = width - (fused ? 1 : 2) * frameThickness const innerX = (fused * frameThickness) / 2 From 1d74b6765a9df8f53c19c8f1eef148adeaf0b864 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Mon, 12 Oct 2026 23:11:05 -0400 Subject: [PATCH 08/16] fix(editor): Merge windows says why in the editor's words and units MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting two windows that cannot merge showed the agents' refusal under the button, node ids and metres included ("window_kcwq… and window_9mkd… are 0.31 m apart on wall wall_fdn2…"), in a project set to feet. The offer now hands the panel the refusal itself, and the panel words its code for a person, measures in the editor's units: "They are too far apart. Windows merge with at most 1'0" between them." Co-Authored-By: Claude Opus 5.5 --- .../core/src/building/merge-windows.test.ts | 22 ++---- packages/core/src/building/merge-windows.ts | 12 +-- .../ui/panels/multi-selection-panel.tsx | 75 +++++++++++++++---- wiki/architecture/tools.md | 2 +- 4 files changed, 74 insertions(+), 37 deletions(-) diff --git a/packages/core/src/building/merge-windows.test.ts b/packages/core/src/building/merge-windows.test.ts index 40ea67aa69..cd89e89371 100644 --- a/packages/core/src/building/merge-windows.test.ts +++ b/packages/core/src/building/merge-windows.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test' import { type AnyNode, LevelNode, WallNode, WindowNode } from '../schema' -import { MERGE_GAP_M, mergeWindows, windowMergeOffer } from './merge-windows' +import { MERGE_GAP_M, windowMergeOffer } from './merge-windows' // The Merge windows button: offered when exactly two windows are selected, disabled with the // reason when they cannot join, and silent for any other selection. @@ -40,22 +40,14 @@ describe('windowMergeOffer', () => { expect(windowMergeOffer(scene, ['window_a', 'missing'])).toBeNull() }) - test('is offered with no reason for two windows that can join', () => { - expect(windowMergeOffer(scene, ['window_a', 'window_b'])).toEqual({ reason: null }) + test('is offered with no refusal for two windows that can join', () => { + expect(windowMergeOffer(scene, ['window_a', 'window_b'])).toEqual({ refusal: null }) }) - test('is offered with the reason, in the words of the refusal, for two that cannot', () => { - const offer = windowMergeOffer(scene, ['window_a', 'window_far']) + test('is offered with the refusal, for the panel to word, for two that cannot', () => { + const refusal = windowMergeOffer(scene, ['window_a', 'window_far'])?.refusal - expect(offer?.reason).toContain(`${MERGE_GAP_M.toFixed(2)} m`) - expect(offer?.reason).toBe( - (() => { - try { - mergeWindows(scene, 'window_a', 'window_far') - } catch (error) { - return (error as Error).message - } - })(), - ) + expect(refusal?.code).toBe('windows_not_adjacent') + expect(refusal?.details).toMatchObject({ limit: MERGE_GAP_M }) }) }) diff --git a/packages/core/src/building/merge-windows.ts b/packages/core/src/building/merge-windows.ts index 1ac41e1411..ab5af28337 100644 --- a/packages/core/src/building/merge-windows.ts +++ b/packages/core/src/building/merge-windows.ts @@ -1,4 +1,4 @@ -import { refuse } from '../agent-tools/refusal' +import { type AgentRefusal, isAgentRefusal, refuse } from '../agent-tools/refusal' import { CORNER_REACH_M, cornerAngle, @@ -262,18 +262,20 @@ function mergeAtCorner(nodes: Nodes, first: Side, second: Side): WindowMerge { /** * What the Merge windows button does for a selection: nothing unless exactly two windows are - * selected; otherwise whether they can join, and if not, why, in the refusal's own words. + * selected; otherwise whether they can join, and if not, the refusal, whose code and details the + * panel words for a person (its message is written for agents, with node ids and metres). */ export function windowMergeOffer( nodes: Nodes, selectedIds: readonly string[], -): { reason: string | null } | null { +): { refusal: AgentRefusal | null } | null { if (selectedIds.length !== 2) return null if (selectedIds.some((id) => nodes[id]?.type !== 'window')) return null try { mergeWindows(nodes, selectedIds[0]!, selectedIds[1]!) - return { reason: null } + return { refusal: null } } catch (error) { - return { reason: error instanceof Error ? error.message : 'These windows cannot be merged.' } + if (isAgentRefusal(error)) return { refusal: error } + throw error } } diff --git a/packages/editor/src/components/ui/panels/multi-selection-panel.tsx b/packages/editor/src/components/ui/panels/multi-selection-panel.tsx index 9ab8a01d53..d01eef511e 100644 --- a/packages/editor/src/components/ui/panels/multi-selection-panel.tsx +++ b/packages/editor/src/components/ui/panels/multi-selection-panel.tsx @@ -1,6 +1,7 @@ 'use client' import { + type AnyNode, type AnyNodeId, type BuildingNode, resolveBuildingForLevel, @@ -12,6 +13,7 @@ import { useViewer } from '@pascal-app/viewer' import { Building2, Copy, Group, Merge, Trash2, Ungroup } from 'lucide-react' import { useMemo } from 'react' import { deleteSelection, duplicateSelectionAndPickUp } from '../../editor/group-actions' +import { formatLinearMeasurement } from '../../../lib/measurements' import { triggerSFX } from '../../../lib/sfx-bus' import { writeWindowChanges } from '../../../lib/window-changes' import { collectSelectableCandidateIds } from '../../tools/select/select-candidates' @@ -48,22 +50,72 @@ function selectBuilding(buildingId: AnyNodeId) { } /** - * Merge windows (the owner, 8 October): with exactly two windows selected, one window across both, - * or one corner window when they sit on two walls that meet. Windows ride their wall, so they have - * no floating group menu, and this is where the wall merge's twin lives. The window selected first - * keeps its height, sill and type; when they cannot join, the button says why. + * Why two selected windows will not merge, for the person who selected them: the refusal's own + * message is the agents' (node ids, metres), so the panel words its code, in the editor's units. + */ +function mergeRefusalNote( + refusal: { code: string; details: Record }, + nodes: Record, + length: (meters: number) => string, +): string { + const { blockingId, limit } = refusal.details as { blockingId?: string; limit?: number } + const reach = length(limit ?? 0) + const blocking = nodes[blockingId ?? ''] + const blockingKind = !blocking + ? 'something' + : blocking.type === 'item' + ? 'an item' + : `a ${blocking.type}` + switch (refusal.code) { + case 'windows_already_joined': + return 'They already wrap this corner.' + case 'window_already_joined': + return 'One of them already wraps another corner. Unwrap it first.' + case 'window_not_on_wall': + return 'A roof or dormer window cannot join another window.' + case 'windows_not_adjacent': + return `They are too far apart. Windows merge with at most ${reach} between them.` + case 'opening_between': + return `There is ${blockingKind} between them.` + case 'walls_not_meeting': + return 'Their walls do not meet at a corner.' + case 'corner_angle': + return 'Their walls meet at too sharp or too flat an angle for a corner window.' + case 'window_not_at_corner': + return `One of them is too far from the corner. A window joins one within ${reach} of it.` + case 'height_exceeds_wall': + return 'The window selected first is taller than the other wall. Select the other one first.' + case 'opening_overlap': + return `Sliding them to the corner would overlap ${blockingKind}.` + default: + return 'These windows cannot be merged.' + } +} + +/** + * Merge windows: with exactly two windows selected, one window across both, or one corner window + * when they sit on two walls that meet. Windows ride their wall, so they have no floating group + * menu, and this is where the wall merge's twin lives. The window selected first keeps its height, + * sill and type; when they cannot join, the panel says why. */ function MergeWindowsAction() { const selectedIds = useViewer((s) => s.selection.selectedIds) const nodes = useScene((s) => s.nodes) const readOnly = useScene((s) => s.readOnly) + const unit = useViewer((s) => s.unit) + const metricNotation = useViewer((s) => s.metricNotation) const offer = useMemo(() => windowMergeOffer(nodes, selectedIds), [nodes, selectedIds]) if (!offer) return null + const note = offer.refusal + ? mergeRefusalNote(offer.refusal, nodes, (meters) => + formatLinearMeasurement(meters, unit, metricNotation), + ) + : null return ( <> } label="Merge windows" onClick={() => { @@ -78,18 +130,9 @@ function MergeWindowsAction() { triggerSFX('sfx:structure-build') useViewer.getState().setSelection({ selectedIds: merge.windowIds as AnyNodeId[] }) }} - title={ - offer.reason ?? 'Merge windows: the one you selected first keeps its height, sill and type' - } + title={note ?? 'Merge windows: the one you selected first keeps its height, sill and type'} /> - {offer.reason ? ( -

- {offer.reason} -

- ) : null} + {note ?

{note}

: null} ) } diff --git a/wiki/architecture/tools.md b/wiki/architecture/tools.md index e3f2ed7da4..6f450630e9 100644 --- a/wiki/architecture/tools.md +++ b/wiki/architecture/tools.md @@ -139,7 +139,7 @@ Concretely, door/window placement/move keeps these in lockstep across `{door,win - **Snap target**: nearest wall to the true cursor (shared `findClosestWallInPlan` / wall raycast), free-follow off-wall, commit only on a host. - **Move SFX**: a soft `sfx:grid-snap` click per grid step while sliding (free-follow plan XZ or on-wall along-X, quantized + deduped so it isn't a machine-gun) and a soft `sfx:item-pick` cue on the floor→wall snap. Both tools carry an identical `tickGridStep` / `tickWallSnap` pair — keep them in sync. - **R-flip** facing mid-placement, **Alt (hold)** to force-place past snapping and collisions (guides stay visible) and **Shift (tap)** to cycle the snapping mode — the rule above, never a held-Shift bypass — faithful ghost/symbol, deterministic single-undo commit. -- **Corner windows.** A window that ends at a corner has a **Corner** section in its panel: *Wrap the corner* adds the matching window on the other wall (the same width, height, sill, type and frame), *This side* and *Other side* set each side's width with its corner end pinned, *Fused glass* or *Corner post* sets the corner, and *Unwrap* leaves two plain windows. The agent's `add_corner_window` builds the same pair (core `planCornerWindow`, `wrapCorner`, `unwrapCorner`); the wall cut runs past the wall's end so the corner opens between the sides at any angle. **Merge windows** (core `mergeWindows`, the agent's `merge_windows`, one operation for both surfaces): select two windows and use *Merge windows* in the multi-selection panel (the panel gives the reason when it will not merge). Two windows on walls that meet at a corner, each within `CORNER_REACH_M` (0.30 m) of it, become one corner window: each slides to the corner keeping its width, the glass is fused, and both take the first selected window's height, sill and type. Two windows side by side on one wall, at most `MERGE_GAP_M` (0.30 m) apart with nothing between, become one window spanning the outer edges, from the lower sill to the higher head. *Wrap the corner* uses the same operation, so a window already waiting at the other wall's corner end is adopted rather than doubled. While placing or dragging a window near a corner where one waits (in 3D, and dragging it in the plan), the ghost snaps its edge to the corner (`cornerSnap`: enter within 0.30 m, leave past 0.40 m so it does not flicker; a short ease, none under reduced motion), the waiting window is outlined with the hover colour, the HUD reads *Join as a corner window*, and the click or release fuses the pair in the same undo step. Where no window waits the snap still shows, the HUD reads *Corner window*, and the click or release wraps the corner, making the partner on the other wall. Where a wall end within 0.30 m is not a corner a window can wrap, the HUD says why instead of staying silent (`cornerBlock`): no other wall ends there, three or more do, they meet at under 10° or over 170°, either wall is curved or a curtain wall, or the other wall has no room for the matching window. **Alt** places or moves it plain. +- **Corner windows.** A window that ends at a corner has a **Corner** section in its panel: *Wrap the corner* adds the matching window on the other wall (the same width, height, sill, type and frame), *This side* and *Other side* set each side's width with its corner end pinned, *Fused glass* or *Corner post* sets the corner, and *Unwrap* leaves two plain windows. The agent's `add_corner_window` builds the same pair (core `planCornerWindow`, `wrapCorner`, `unwrapCorner`); the wall cut runs past the wall's end so the corner opens between the sides at any angle. **Merge windows** (core `mergeWindows`, the agent's `merge_windows`, one operation for both surfaces): select two windows and use *Merge windows* in the multi-selection panel (the panel gives the reason when it will not merge, in the editor's units, from the refusal's code: the refusal's own message is the agents'). Two windows on walls that meet at a corner, each within `CORNER_REACH_M` (0.30 m) of it, become one corner window: each slides to the corner keeping its width, the glass is fused, and both take the first selected window's height, sill and type. Two windows side by side on one wall, at most `MERGE_GAP_M` (0.30 m) apart with nothing between, become one window spanning the outer edges, from the lower sill to the higher head. *Wrap the corner* uses the same operation, so a window already waiting at the other wall's corner end is adopted rather than doubled. While placing or dragging a window near a corner where one waits (in 3D, and dragging it in the plan), the ghost snaps its edge to the corner (`cornerSnap`: enter within 0.30 m, leave past 0.40 m so it does not flicker; a short ease, none under reduced motion), the waiting window is outlined with the hover colour, the HUD reads *Join as a corner window*, and the click or release fuses the pair in the same undo step. Where no window waits the snap still shows, the HUD reads *Corner window*, and the click or release wraps the corner, making the partner on the other wall. Where a wall end within 0.30 m is not a corner a window can wrap, the HUD says why instead of staying silent (`cornerBlock`): no other wall ends there, three or more do, they meet at under 10° or over 170°, either wall is curved or a curtain wall, or the other wall has no room for the matching window. **Alt** places or moves it plain. - **O / L** cycle the door and window tools' Type and Style chips (`ToolHint.chip`, by key or click): the choices of the panel's Type and Style rows, written from the same core fields (`doorTypeFields`, `DOOR_TYPE_SIZES` and `doorStyleLook` for a door; `windowTypeFields` and the window style presets for a window), so a door placed by hand matches one changed in the panel. A window placed as Bay or Bow has no sill; every other type has one. A window style shapes a Fixed window's panes, which only a Fixed window draws, so the panel's Style row and the tool's L chip show for a Fixed window only (cycling O away from Fixed hides L), and `add_window` refuses a style on another type (`style_needs_fixed_window`). The Style row and the L chip offer one entry per look (`WINDOW_STYLE_CHOICES`); `picture`, which draws what `single` draws, stays an accepted alias for agents. Tells that you've broken parity: a sound/guide/snap that fires in 3D but is silent in 2D (or vice-versa), or a fix landed in one move file but not its sibling. The two move files are deliberately near-mirrors; diff them when in doubt. From b7f1fda7f93e92b59ad90a60d43161276e2fe89b Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Mon, 12 Oct 2026 23:11:27 -0400 Subject: [PATCH 09/16] fix(editor): the HUD's note at a wall end that is no corner is a "!" row Near a free wall end the window HUD showed "No wall meets here" beside a key cap reading "Corner", as if Corner were a key to press. The note now takes the HUD's notice row, the "!" a placement that does not fit already uses. Co-Authored-By: Claude Opus 5.5 --- packages/core/src/store/use-corner-snap-hint.ts | 3 ++- wiki/architecture/tools.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/core/src/store/use-corner-snap-hint.ts b/packages/core/src/store/use-corner-snap-hint.ts index 3d3b4dbc47..ed8a37a565 100644 --- a/packages/core/src/store/use-corner-snap-hint.ts +++ b/packages/core/src/store/use-corner-snap-hint.ts @@ -40,5 +40,6 @@ export function cornerHintRows(hint: CornerSnapHint): CornerHintRow[] { }, { keys: ['Alt'], label: 'Place without the corner snap' }, ] - return [{ keys: ['Corner'], label: CORNER_BLOCK_NOTES[hint] }] + // The HUD's notice row, as for a placement that does not fit: a note, not a key to press. + return [{ keys: ['!'], label: CORNER_BLOCK_NOTES[hint] }] } diff --git a/wiki/architecture/tools.md b/wiki/architecture/tools.md index 6f450630e9..cecfd0a7be 100644 --- a/wiki/architecture/tools.md +++ b/wiki/architecture/tools.md @@ -139,7 +139,7 @@ Concretely, door/window placement/move keeps these in lockstep across `{door,win - **Snap target**: nearest wall to the true cursor (shared `findClosestWallInPlan` / wall raycast), free-follow off-wall, commit only on a host. - **Move SFX**: a soft `sfx:grid-snap` click per grid step while sliding (free-follow plan XZ or on-wall along-X, quantized + deduped so it isn't a machine-gun) and a soft `sfx:item-pick` cue on the floor→wall snap. Both tools carry an identical `tickGridStep` / `tickWallSnap` pair — keep them in sync. - **R-flip** facing mid-placement, **Alt (hold)** to force-place past snapping and collisions (guides stay visible) and **Shift (tap)** to cycle the snapping mode — the rule above, never a held-Shift bypass — faithful ghost/symbol, deterministic single-undo commit. -- **Corner windows.** A window that ends at a corner has a **Corner** section in its panel: *Wrap the corner* adds the matching window on the other wall (the same width, height, sill, type and frame), *This side* and *Other side* set each side's width with its corner end pinned, *Fused glass* or *Corner post* sets the corner, and *Unwrap* leaves two plain windows. The agent's `add_corner_window` builds the same pair (core `planCornerWindow`, `wrapCorner`, `unwrapCorner`); the wall cut runs past the wall's end so the corner opens between the sides at any angle. **Merge windows** (core `mergeWindows`, the agent's `merge_windows`, one operation for both surfaces): select two windows and use *Merge windows* in the multi-selection panel (the panel gives the reason when it will not merge, in the editor's units, from the refusal's code: the refusal's own message is the agents'). Two windows on walls that meet at a corner, each within `CORNER_REACH_M` (0.30 m) of it, become one corner window: each slides to the corner keeping its width, the glass is fused, and both take the first selected window's height, sill and type. Two windows side by side on one wall, at most `MERGE_GAP_M` (0.30 m) apart with nothing between, become one window spanning the outer edges, from the lower sill to the higher head. *Wrap the corner* uses the same operation, so a window already waiting at the other wall's corner end is adopted rather than doubled. While placing or dragging a window near a corner where one waits (in 3D, and dragging it in the plan), the ghost snaps its edge to the corner (`cornerSnap`: enter within 0.30 m, leave past 0.40 m so it does not flicker; a short ease, none under reduced motion), the waiting window is outlined with the hover colour, the HUD reads *Join as a corner window*, and the click or release fuses the pair in the same undo step. Where no window waits the snap still shows, the HUD reads *Corner window*, and the click or release wraps the corner, making the partner on the other wall. Where a wall end within 0.30 m is not a corner a window can wrap, the HUD says why instead of staying silent (`cornerBlock`): no other wall ends there, three or more do, they meet at under 10° or over 170°, either wall is curved or a curtain wall, or the other wall has no room for the matching window. **Alt** places or moves it plain. +- **Corner windows.** A window that ends at a corner has a **Corner** section in its panel: *Wrap the corner* adds the matching window on the other wall (the same width, height, sill, type and frame), *This side* and *Other side* set each side's width with its corner end pinned, *Fused glass* or *Corner post* sets the corner, and *Unwrap* leaves two plain windows. The agent's `add_corner_window` builds the same pair (core `planCornerWindow`, `wrapCorner`, `unwrapCorner`); the wall cut runs past the wall's end so the corner opens between the sides at any angle. **Merge windows** (core `mergeWindows`, the agent's `merge_windows`, one operation for both surfaces): select two windows and use *Merge windows* in the multi-selection panel (the panel gives the reason when it will not merge, in the editor's units, from the refusal's code: the refusal's own message is the agents'). Two windows on walls that meet at a corner, each within `CORNER_REACH_M` (0.30 m) of it, become one corner window: each slides to the corner keeping its width, the glass is fused, and both take the first selected window's height, sill and type. Two windows side by side on one wall, at most `MERGE_GAP_M` (0.30 m) apart with nothing between, become one window spanning the outer edges, from the lower sill to the higher head. *Wrap the corner* uses the same operation, so a window already waiting at the other wall's corner end is adopted rather than doubled. While placing or dragging a window near a corner where one waits (in 3D, and dragging it in the plan), the ghost snaps its edge to the corner (`cornerSnap`: enter within 0.30 m, leave past 0.40 m so it does not flicker; a short ease, none under reduced motion), the waiting window is outlined with the hover colour, the HUD reads *Join as a corner window*, and the click or release fuses the pair in the same undo step. Where no window waits the snap still shows, the HUD reads *Corner window*, and the click or release wraps the corner, making the partner on the other wall. Where a wall end within 0.30 m is not a corner a window can wrap, the HUD's "!" row says why instead of staying silent (`cornerBlock`): no other wall ends there, three or more do, they meet at under 10° or over 170°, either wall is curved or a curtain wall, or the other wall has no room for the matching window. **Alt** places or moves it plain. - **O / L** cycle the door and window tools' Type and Style chips (`ToolHint.chip`, by key or click): the choices of the panel's Type and Style rows, written from the same core fields (`doorTypeFields`, `DOOR_TYPE_SIZES` and `doorStyleLook` for a door; `windowTypeFields` and the window style presets for a window), so a door placed by hand matches one changed in the panel. A window placed as Bay or Bow has no sill; every other type has one. A window style shapes a Fixed window's panes, which only a Fixed window draws, so the panel's Style row and the tool's L chip show for a Fixed window only (cycling O away from Fixed hides L), and `add_window` refuses a style on another type (`style_needs_fixed_window`). The Style row and the L chip offer one entry per look (`WINDOW_STYLE_CHOICES`); `picture`, which draws what `single` draws, stays an accepted alias for agents. Tells that you've broken parity: a sound/guide/snap that fires in 3D but is silent in 2D (or vice-versa), or a fix landed in one move file but not its sibling. The two move files are deliberately near-mirrors; diff them when in doubt. From 8f1c9e10fbd951133f30764f84a0ee2118844103 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Mon, 12 Oct 2026 23:11:49 -0400 Subject: [PATCH 10/16] fix(nodes): an edit a corner window shares with its partner undoes in one step Changing a corner window's height in its panel wrote the window and then its partner as two undo steps, so one undo left the pair on two heights. The frame's thickness and depth, also shared, were not carried to the partner at all. Each shared edit (height, frame, sill, type) now writes both windows in one step. Co-Authored-By: Claude Opus 5.5 --- packages/nodes/src/window/panel.tsx | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/packages/nodes/src/window/panel.tsx b/packages/nodes/src/window/panel.tsx index 53c7a064e2..6085bc068f 100644 --- a/packages/nodes/src/window/panel.tsx +++ b/packages/nodes/src/window/panel.tsx @@ -4,6 +4,7 @@ import { type AnyNode, type AnyNodeId, cornerPair, + runAsSingleSceneHistoryStep, scriptImages, useInteractive, useScene, @@ -133,11 +134,14 @@ export default function WindowPanel() { }) if (!hasChange) return - useScene.getState().updateNode(selectedId as AnyNode['id'], updates) - const scene = useScene.getState() - scene.dirtyNodes.add(selectedId as AnyNodeId) - if (liveNode.parentId) scene.dirtyNodes.add(liveNode.parentId as AnyNodeId) - syncCornerPartner(selectedId, updates) + // A corner window's partner follows in the same undo step, so undo never splits the pair. + runAsSingleSceneHistoryStep(useScene, () => { + useScene.getState().updateNode(selectedId as AnyNode['id'], updates) + const scene = useScene.getState() + scene.dirtyNodes.add(selectedId as AnyNodeId) + if (liveNode.parentId) scene.dirtyNodes.add(liveNode.parentId as AnyNodeId) + syncCornerPartner(selectedId, updates) + }) }, [selectedId], ) @@ -195,8 +199,15 @@ export default function WindowPanel() { const previewWindowUpdate = (key: K, value: WindowNode[K]) => preview?.preview({ [key]: value } as Partial) + // A committed change a corner window shares with its partner (its height, frame, sill) takes the + // partner along, in the same undo step. + const commitPreview = (updates: Partial) => + runAsSingleSceneHistoryStep(useScene, () => { + preview?.commit(updates) + if (selectedId) syncCornerPartner(selectedId, updates) + }) const commitWindowPreview = (key: K, value: WindowNode[K]) => - preview?.commit({ [key]: value } as Partial) + commitPreview({ [key]: value } as Partial) const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) @@ -637,11 +648,7 @@ export default function WindowPanel() { max={limits?.height} min={0.01} onChange={(v) => preview?.preview(getDimensionUpdates({ height: v }))} - onCommit={(v) => { - const updates = getDimensionUpdates({ height: v }) - preview?.commit(updates) - if (selectedId) syncCornerPartner(selectedId, updates) - }} + onCommit={(v) => commitPreview(getDimensionUpdates({ height: v }))} onCancel={() => preview?.cancel()} previewWhileTyping precision={2} From 5f07bf08b77d967ace8a19324fe027ccbe360ef6 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Mon, 12 Oct 2026 23:12:17 -0400 Subject: [PATCH 11/16] feat(nodes): the plan draws a corner window through its corner In 3D a corner window opens the corner; the plan still drew each side square at its wall's end, so the corner square stayed wall, a post that is not there. Each side of a corner pair now runs to the mitre its wall meets the other along, so the plan shows one window turning the corner, the joint mitred as a drafted corner window is. Dragging a window toward a corner in the plan outlined the window waiting on the other wall only in 3D: the plan drew no hover on windows. A hovered window takes the walls' hover stroke, so the cue shows in both views. Co-Authored-By: Claude Opus 5.5 --- packages/nodes/src/window/floorplan.test.ts | 53 +++++++ packages/nodes/src/window/floorplan.ts | 157 +++++++++++++------- wiki/architecture/tools.md | 2 +- 3 files changed, 156 insertions(+), 56 deletions(-) diff --git a/packages/nodes/src/window/floorplan.test.ts b/packages/nodes/src/window/floorplan.test.ts index 2672d2b255..46e527a3be 100644 --- a/packages/nodes/src/window/floorplan.test.ts +++ b/packages/nodes/src/window/floorplan.test.ts @@ -50,3 +50,56 @@ describe('buildWindowFloorplan wall-local plane offset', () => { expect(frame?.max).toBeCloseTo(-0.1016, 9) }) }) + +describe('buildWindowFloorplan at a corner window', () => { + // Two 0.2 m walls meeting at the origin, inside the corner toward +x and +z. + const wallA = WallNode.parse({ id: 'wall_a', start: [4, 0], end: [0, 0], thickness: 0.2 }) + const wallB = WallNode.parse({ id: 'wall_b', start: [0, 0], end: [0, 3], thickness: 0.2 }) + const windowA = WindowNode.parse({ + id: 'window_a', + parentId: wallA.id, + wallId: wallA.id, + position: [3.5, 1.5, 0], + width: 1, + corner: { end: 'end', partnerId: 'window_b', post: 'none' }, + }) + const windowB = WindowNode.parse({ + id: 'window_b', + parentId: wallB.id, + wallId: wallB.id, + position: [0.5, 1.5, 0], + width: 1, + corner: { end: 'start', partnerId: 'window_a', post: 'none' }, + }) + const nodes: Record = { + [wallA.id]: wallA, + [wallB.id]: wallB, + [windowA.id]: windowA, + [windowB.id]: windowB, + } + const footprint = (window: WindowNode) => { + const geometry = buildWindowFloorplan(window, { + children: [], + parent: wallA, + resolve: (id: string) => nodes[id], + siblings: [], + } as unknown as GeometryContext) + const children: FloorplanGeometry[] = geometry?.kind === 'group' ? geometry.children : [] + const polygon = children.find((item) => item.kind === 'polygon') + return polygon?.kind === 'polygon' ? polygon.points : [] + } + const reaches = (points: readonly (readonly number[])[], [x, z]: [number, number]) => + points.some((point) => Math.hypot(point[0]! - x, point[1]! - z) < 1e-9) + + test('runs to the walls’ mitre, so the corner between the sides is glass, not wall', () => { + const points = footprint(windowA) + expect(reaches(points, [0.1, 0.1])).toBe(true) + expect(reaches(points, [-0.1, -0.1])).toBe(true) + }) + + test('a window that is no corner pair stops square at its jambs', () => { + const points = footprint({ ...windowA, corner: undefined }) + expect(reaches(points, [0, 0.1])).toBe(true) + expect(reaches(points, [0, -0.1])).toBe(true) + }) +}) diff --git a/packages/nodes/src/window/floorplan.ts b/packages/nodes/src/window/floorplan.ts index ace14cf07d..17f4ad8af5 100644 --- a/packages/nodes/src/window/floorplan.ts +++ b/packages/nodes/src/window/floorplan.ts @@ -1,11 +1,12 @@ import type { + AnyNodeId, FloorplanGeometry, FloorplanPoint, GeometryContext, WallNode, WindowNode, } from '@pascal-app/core' -import { getWallBodyCenterOffset } from '@pascal-app/core' +import { cornerPair, getWallBodyCenterOffset } from '@pascal-app/core' import { floorplanGeometryMetadata, readFloorplanContext } from '@pascal-app/editor' import { buildOpeningMarkAnnotation, @@ -14,6 +15,69 @@ import { import { buildOpeningPlacementDimensions } from '../shared/opening-placement-dimensions' import { resolveOpeningPlanPlane } from '../shared/opening-plane-offset' +type Vec = readonly [number, number] +const dot = (a: Vec, b: Vec) => a[0] * b[0] + a[1] * b[1] + +/** + * Where a corner window ends at its corner: on the mitre its wall meets the partner's wall along, + * which the 3D cut opens up to, so the two sides meet there and the corner reads as glass rather + * than a wall post. `at(v)` is the mitre's along-wall position (from the wall's start) at a + * perpendicular offset `v` from the wall's reference line. Null when the window is not one side of + * a corner pair. + */ +function cornerMitre(node: WindowNode, wall: WallNode, ctx: GeometryContext) { + const partnerId = node.corner?.partnerId as AnyNodeId | undefined + const partner = partnerId ? ctx.resolve(partnerId) : undefined + const partnerWall = partner?.parentId + ? ctx.resolve(partner.parentId as AnyNodeId) + : undefined + if (partner?.type !== 'window' || partnerWall?.type !== 'wall') return null + const pair = cornerPair( + { [node.id]: node, [wall.id]: wall, [partner.id]: partner, [partnerWall.id]: partnerWall }, + node, + ) + if (!pair) return null + const into = (from: Vec, to: Vec): Vec => { + const length = Math.hypot(to[0] - from[0], to[1] - from[1]) + return [(to[0] - from[0]) / length, (to[1] - from[1]) / length] + } + const length = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) + const dir = into(wall.start, wall.end) + const perp: Vec = [-dir[1], dir[0]] + const away = pair.end === 'start' ? dir : ([-dir[0], -dir[1]] as const) + const otherDir = into(partnerWall.start, partnerWall.end) + const otherPerp: Vec = [-otherDir[1], otherDir[0]] + const otherAway = + partner.corner?.end === 'start' ? otherDir : ([-otherDir[0], -otherDir[1]] as const) + const half = (wall.thickness ?? 0.1) / 2 + const otherHalf = (partnerWall.thickness ?? 0.1) / 2 + // Each wall's face toward the other meets the other's face toward it; the outer faces meet too. + const side = Math.sign(dot(otherAway, perp)) || 1 + const otherSide = Math.sign(dot(away, otherPerp)) || 1 + const inner = getWallBodyCenterOffset(wall) + side * half + const outer = getWallBodyCenterOffset(wall) - side * half + const otherInner = getWallBodyCenterOffset(partnerWall) + otherSide * otherHalf + const otherOuter = getWallBodyCenterOffset(partnerWall) - otherSide * otherHalf + // How far from the corner, along this wall, its face at `v` meets the other's face at `q`; + // capped where a wall's own miter stops, at five thicknesses. + const reach = 10 * half + const det = away[1] * otherAway[0] - away[0] * otherAway[1] + const meet = (v: number, q: number) => { + const rhs: Vec = [q * otherPerp[0] - v * perp[0], q * otherPerp[1] - v * perp[1]] + const t = (rhs[1] * otherAway[0] - rhs[0] * otherAway[1]) / det + return Math.max(-reach, Math.min(reach, t)) + } + const tInner = meet(inner, otherInner) + const tOuter = meet(outer, otherOuter) + return { + end: pair.end, + at: (v: number) => { + const t = tInner + ((v - inner) * (tOuter - tInner)) / (outer - inner) + return pair.end === 'start' ? t : length - t + }, + } +} + /** * Stage C floor-plan builder for window. Mirrors the legacy * floorplan-panel window rendering: @@ -59,76 +123,59 @@ export function buildWindowFloorplan( const cz = z1 + dirZ * distance + perpZ * across const halfWidth = width / 2 const halfDepth = depth / 2 - // An offset frame stands clear of the wall centre, but the wall is still - // cut through its whole thickness: that hole is drawn too. - const wallX = x1 + dirX * distance + perpX * bodyOffset - const wallZ = z1 + dirZ * distance + perpZ * bodyOffset const halfWallDepth = wallDepth / 2 - const cutoutPoints: readonly FloorplanPoint[] | null = - plane.offset === 0 - ? null - : [ - [ - wallX - dirX * halfWidth + perpX * halfWallDepth, - wallZ - dirZ * halfWidth + perpZ * halfWallDepth, - ], - [ - wallX + dirX * halfWidth + perpX * halfWallDepth, - wallZ + dirZ * halfWidth + perpZ * halfWallDepth, - ], - [ - wallX + dirX * halfWidth - perpX * halfWallDepth, - wallZ + dirZ * halfWidth - perpZ * halfWallDepth, - ], - [ - wallX - dirX * halfWidth - perpX * halfWallDepth, - wallZ - dirZ * halfWidth - perpZ * halfWallDepth, - ], - ] - - const points: readonly FloorplanPoint[] = [ - [cx - dirX * halfWidth + perpX * halfDepth, cz - dirZ * halfWidth + perpZ * halfDepth], - [cx + dirX * halfWidth + perpX * halfDepth, cz + dirZ * halfWidth + perpZ * halfDepth], - [cx + dirX * halfWidth - perpX * halfDepth, cz + dirZ * halfWidth - perpZ * halfDepth], - [cx - dirX * halfWidth - perpX * halfDepth, cz - dirZ * halfWidth - perpZ * halfDepth], + const plan = (along: number, v: number): FloorplanPoint => [ + x1 + dirX * along + perpX * v, + z1 + dirZ * along + perpZ * v, ] + // Where the window's side edge at perpendicular offset `v` lies along the wall, `inset` in from + // its jamb; a corner window's corner side runs to the mitre, with no jamb there. + const mitre = cornerMitre(node, wall, ctx) + const edge = (side: 'start' | 'end', v: number, inset = 0) => + mitre?.end === side + ? mitre.at(v) + : side === 'start' + ? distance - halfWidth + inset + : distance + halfWidth - inset + const rectangle = (v: number, half: number, inset = 0): readonly FloorplanPoint[] => [ + plan(edge('start', v + half, inset), v + half), + plan(edge('end', v + half, inset), v + half), + plan(edge('end', v - half, inset), v - half), + plan(edge('start', v - half, inset), v - half), + ] + // An offset frame stands clear of the wall centre, but the wall is still + // cut through its whole thickness: that hole is drawn too. + const cutoutPoints = plane.offset === 0 ? null : rectangle(bodyOffset, halfWallDepth) + const points = rectangle(across, halfDepth) const view = ctx.viewState const palette = view?.palette const isSelected = view?.selected ?? false const isHighlighted = view?.highlighted ?? false + const isHovered = view?.hovered ?? false const showSelectedChrome = isSelected || isHighlighted // Same selection treatment as door — selected windows get a full - // orange body + outline so they read as the active target. - const accentColor = showSelectedChrome ? '#f97316' : 'rgba(31, 41, 55, 0.92)' + // orange body + outline so they read as the active target. Hover takes the + // walls' hover stroke: the window a dragged one would join at a corner is + // outlined that way, as the 3D view outlines it. + const accentColor = showSelectedChrome + ? '#f97316' + : isHovered && palette + ? palette.wallHoverStroke + : 'rgba(31, 41, 55, 0.92)' const fillColor = showSelectedChrome ? '#fed7aa' : 'rgba(255, 255, 255, 0.96)' // Inner inset rectangle (the glass pane outline). Tangent inset // pulls the long sides in slightly; normal inset reduces the depth. const tangentInset = Math.min(width * 0.08, 0.12) const normalInset = Math.min(depth * 0.22, 0.07) - const innerStartA: FloorplanPoint = [ - cx - dirX * (halfWidth - tangentInset) + perpX * (halfDepth - normalInset), - cz - dirZ * (halfWidth - tangentInset) + perpZ * (halfDepth - normalInset), - ] - const innerEndA: FloorplanPoint = [ - cx + dirX * (halfWidth - tangentInset) + perpX * (halfDepth - normalInset), - cz + dirZ * (halfWidth - tangentInset) + perpZ * (halfDepth - normalInset), - ] - const innerEndB: FloorplanPoint = [ - cx + dirX * (halfWidth - tangentInset) - perpX * (halfDepth - normalInset), - cz + dirZ * (halfWidth - tangentInset) - perpZ * (halfDepth - normalInset), - ] - const innerStartB: FloorplanPoint = [ - cx - dirX * (halfWidth - tangentInset) - perpX * (halfDepth - normalInset), - cz - dirZ * (halfWidth - tangentInset) - perpZ * (halfDepth - normalInset), - ] + const innerPane = rectangle(across, halfDepth - normalInset, tangentInset) // Center mullion — from the midpoint of the left edge to the // midpoint of the right edge of the cutout. - const mullionStart: FloorplanPoint = [cx - dirX * halfWidth, cz - dirZ * halfWidth] - const mullionEnd: FloorplanPoint = [cx + dirX * halfWidth, cz + dirZ * halfWidth] + const mullionStart = plan(edge('start', across), across) + const mullionEnd = plan(edge('end', across), across) // A sheet draws the standard window symbol in plan-metre ink, so it prints // the same in every back end: the two wall faces carried across the opening @@ -150,7 +197,7 @@ export function buildWindowFloorplan( points: cutoutPoints, fill: fillColor, stroke: accentColor, - strokeWidth: showSelectedChrome ? 1.9 : 1.25, + strokeWidth: showSelectedChrome || isHovered ? 1.9 : 1.25, vectorEffect: 'non-scaling-stroke', strokeLinejoin: 'round', metadata: floorplanGeometryMetadata({ annotationObstacle: 'bounds' }), @@ -187,7 +234,7 @@ export function buildWindowFloorplan( points, fill: fillColor, stroke: accentColor, - strokeWidth: showSelectedChrome ? 1.9 : 1.25, + strokeWidth: showSelectedChrome || isHovered ? 1.9 : 1.25, vectorEffect: 'non-scaling-stroke', strokeLinejoin: 'round', metadata: floorplanGeometryMetadata({ annotationObstacle: 'bounds' }), @@ -195,7 +242,7 @@ export function buildWindowFloorplan( // Inset glass-pane outline. { kind: 'polygon', - points: [innerStartA, innerEndA, innerEndB, innerStartB], + points: innerPane, fill: 'none', stroke: accentColor, strokeOpacity: 0.6, diff --git a/wiki/architecture/tools.md b/wiki/architecture/tools.md index cecfd0a7be..703a298535 100644 --- a/wiki/architecture/tools.md +++ b/wiki/architecture/tools.md @@ -139,7 +139,7 @@ Concretely, door/window placement/move keeps these in lockstep across `{door,win - **Snap target**: nearest wall to the true cursor (shared `findClosestWallInPlan` / wall raycast), free-follow off-wall, commit only on a host. - **Move SFX**: a soft `sfx:grid-snap` click per grid step while sliding (free-follow plan XZ or on-wall along-X, quantized + deduped so it isn't a machine-gun) and a soft `sfx:item-pick` cue on the floor→wall snap. Both tools carry an identical `tickGridStep` / `tickWallSnap` pair — keep them in sync. - **R-flip** facing mid-placement, **Alt (hold)** to force-place past snapping and collisions (guides stay visible) and **Shift (tap)** to cycle the snapping mode — the rule above, never a held-Shift bypass — faithful ghost/symbol, deterministic single-undo commit. -- **Corner windows.** A window that ends at a corner has a **Corner** section in its panel: *Wrap the corner* adds the matching window on the other wall (the same width, height, sill, type and frame), *This side* and *Other side* set each side's width with its corner end pinned, *Fused glass* or *Corner post* sets the corner, and *Unwrap* leaves two plain windows. The agent's `add_corner_window` builds the same pair (core `planCornerWindow`, `wrapCorner`, `unwrapCorner`); the wall cut runs past the wall's end so the corner opens between the sides at any angle. **Merge windows** (core `mergeWindows`, the agent's `merge_windows`, one operation for both surfaces): select two windows and use *Merge windows* in the multi-selection panel (the panel gives the reason when it will not merge, in the editor's units, from the refusal's code: the refusal's own message is the agents'). Two windows on walls that meet at a corner, each within `CORNER_REACH_M` (0.30 m) of it, become one corner window: each slides to the corner keeping its width, the glass is fused, and both take the first selected window's height, sill and type. Two windows side by side on one wall, at most `MERGE_GAP_M` (0.30 m) apart with nothing between, become one window spanning the outer edges, from the lower sill to the higher head. *Wrap the corner* uses the same operation, so a window already waiting at the other wall's corner end is adopted rather than doubled. While placing or dragging a window near a corner where one waits (in 3D, and dragging it in the plan), the ghost snaps its edge to the corner (`cornerSnap`: enter within 0.30 m, leave past 0.40 m so it does not flicker; a short ease, none under reduced motion), the waiting window is outlined with the hover colour, the HUD reads *Join as a corner window*, and the click or release fuses the pair in the same undo step. Where no window waits the snap still shows, the HUD reads *Corner window*, and the click or release wraps the corner, making the partner on the other wall. Where a wall end within 0.30 m is not a corner a window can wrap, the HUD's "!" row says why instead of staying silent (`cornerBlock`): no other wall ends there, three or more do, they meet at under 10° or over 170°, either wall is curved or a curtain wall, or the other wall has no room for the matching window. **Alt** places or moves it plain. +- **Corner windows.** A window that ends at a corner has a **Corner** section in its panel: *Wrap the corner* adds the matching window on the other wall (the same width, height, sill, type and frame), *This side* and *Other side* set each side's width with its corner end pinned, *Fused glass* or *Corner post* sets the corner, and *Unwrap* leaves two plain windows. The agent's `add_corner_window` builds the same pair (core `planCornerWindow`, `wrapCorner`, `unwrapCorner`); the wall cut runs past the wall's end so the corner opens between the sides at any angle, and in the plan each side runs to the walls' mitre, so the corner reads as glass there too. **Merge windows** (core `mergeWindows`, the agent's `merge_windows`, one operation for both surfaces): select two windows and use *Merge windows* in the multi-selection panel (the panel gives the reason when it will not merge, in the editor's units, from the refusal's code: the refusal's own message is the agents'). Two windows on walls that meet at a corner, each within `CORNER_REACH_M` (0.30 m) of it, become one corner window: each slides to the corner keeping its width, the glass is fused, and both take the first selected window's height, sill and type. Two windows side by side on one wall, at most `MERGE_GAP_M` (0.30 m) apart with nothing between, become one window spanning the outer edges, from the lower sill to the higher head. *Wrap the corner* uses the same operation, so a window already waiting at the other wall's corner end is adopted rather than doubled. While placing or dragging a window near a corner where one waits (in 3D, and dragging it in the plan), the ghost snaps its edge to the corner (`cornerSnap`: enter within 0.30 m, leave past 0.40 m so it does not flicker; a short ease, none under reduced motion), the waiting window is outlined with the hover colour (in the plan, the walls' hover stroke), the HUD reads *Join as a corner window*, and the click or release fuses the pair in the same undo step. Where no window waits the snap still shows, the HUD reads *Corner window*, and the click or release wraps the corner, making the partner on the other wall. Where a wall end within 0.30 m is not a corner a window can wrap, the HUD's "!" row says why instead of staying silent (`cornerBlock`): no other wall ends there, three or more do, they meet at under 10° or over 170°, either wall is curved or a curtain wall, or the other wall has no room for the matching window. **Alt** places or moves it plain. - **O / L** cycle the door and window tools' Type and Style chips (`ToolHint.chip`, by key or click): the choices of the panel's Type and Style rows, written from the same core fields (`doorTypeFields`, `DOOR_TYPE_SIZES` and `doorStyleLook` for a door; `windowTypeFields` and the window style presets for a window), so a door placed by hand matches one changed in the panel. A window placed as Bay or Bow has no sill; every other type has one. A window style shapes a Fixed window's panes, which only a Fixed window draws, so the panel's Style row and the tool's L chip show for a Fixed window only (cycling O away from Fixed hides L), and `add_window` refuses a style on another type (`style_needs_fixed_window`). The Style row and the L chip offer one entry per look (`WINDOW_STYLE_CHOICES`); `picture`, which draws what `single` draws, stays an accepted alias for agents. Tells that you've broken parity: a sound/guide/snap that fires in 3D but is silent in 2D (or vice-versa), or a fix landed in one move file but not its sibling. The two move files are deliberately near-mirrors; diff them when in doubt. From 68053a4a633247bb66ce5ae832d9dd7647ece2ac Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Mon, 12 Oct 2026 23:12:40 -0400 Subject: [PATCH 12/16] fix(editor): a window placed from a preset at a corner in the plan stays joined A preset placed in the plan is committed by giving its draft a new id. At a corner the draft had already been joined to its partner, which kept naming the deleted draft, so the pair came out broken: the 3D view drew jambs at the corner and verify_scene saw a window whose partner was gone. The commit now rewrites the nodes the draft names that name it back, so they name the committed node. Co-Authored-By: Claude Opus 5.5 --- .../src/lib/fresh-planar-placement.test.ts | 44 ++++++++++++++++++ .../editor/src/lib/fresh-planar-placement.ts | 45 +++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/packages/editor/src/lib/fresh-planar-placement.test.ts b/packages/editor/src/lib/fresh-planar-placement.test.ts index 639c007eeb..770d1094b1 100644 --- a/packages/editor/src/lib/fresh-planar-placement.test.ts +++ b/packages/editor/src/lib/fresh-planar-placement.test.ts @@ -15,6 +15,8 @@ import { type SceneCommit, subscribeSceneCommits, useScene, + WallNode, + WindowNode, } from '@pascal-app/core' import { z } from 'zod' import { @@ -223,6 +225,48 @@ describe('commitFreshPlacementSubtree', () => { expect((useScene.getState().nodes[LEVEL_ID] as { children: AnyNodeId[] }).children).toEqual([]) }) + test('a window joined at a corner while in hand stays joined: its partner names the committed window', () => { + const wall = WallNode.parse({ id: 'wall_a', parentId: LEVEL_ID, start: [4, 0], end: [0, 0] }) + const other = WallNode.parse({ id: 'wall_b', parentId: LEVEL_ID, start: [0, 0], end: [0, 3] }) + const draft = WindowNode.parse({ + id: 'window_draft', + parentId: wall.id, + wallId: wall.id, + position: [3.5, 1.5, 0], + width: 1, + visible: false, + metadata: { isNew: true }, + corner: { end: 'end', partnerId: 'window_partner', post: 'none' }, + }) + const partner = WindowNode.parse({ + id: 'window_partner', + parentId: other.id, + wallId: other.id, + position: [0.5, 1.5, 0], + width: 1, + corner: { end: 'start', partnerId: draft.id, post: 'none' }, + }) + useScene.setState({ + nodes: { + [LEVEL_ID]: level([wall.id as AnyNodeId, other.id as AnyNodeId]), + [wall.id]: { ...wall, children: [draft.id] } as AnyNode, + [other.id]: { ...other, children: [partner.id] } as AnyNode, + [draft.id]: draft as AnyNode, + [partner.id]: partner as AnyNode, + }, + rootNodeIds: [LEVEL_ID], + collections: {}, + dirtyNodes: new Set(), + } as never) + + const committedId = commitFreshPlacementSubtree(draft.id as AnyNodeId, { visible: true }) + + const nodes = useScene.getState().nodes as Record + expect(committedId).toBeTruthy() + expect((nodes[committedId!] as WindowNode).corner?.partnerId).toBe(partner.id) + expect((nodes[partner.id] as WindowNode).corner?.partnerId).toBe(committedId!) + }) + test("never records inside another owner's pause", () => { pauseSceneHistory(useScene) try { diff --git a/packages/editor/src/lib/fresh-planar-placement.ts b/packages/editor/src/lib/fresh-planar-placement.ts index 6637b6f2ae..ddea9138a3 100644 --- a/packages/editor/src/lib/fresh-planar-placement.ts +++ b/packages/editor/src/lib/fresh-planar-placement.ts @@ -16,6 +16,7 @@ import { type SurfaceRejectReason, useScene, } from '@pascal-app/core' +import { remapNodeReferences } from '@pascal-app/core/clone-scene-graph' import { evaluateRecipe, isProceduralItem } from '@pascal-app/core/procedural-items' import useInteractionScope from '../store/use-interaction-scope' import usePlacementPreview from '../store/use-placement-preview' @@ -39,6 +40,47 @@ function parentIdOf(node: AnyNode): AnyNodeId | undefined { return parentId ?? undefined } +/** + * The nodes the draft root names, outside its subtree and other than its parent, that name it + * back (a corner window's partner, joined while the draft was in hand). The commit gives the + * root a new id, so they are rewritten to name the committed node, never the deleted draft. + */ +function peerReferenceUpdates( + nodes: Readonly>, + root: AnyNode, + subtreeIds: ReadonlySet, + committedId: AnyNodeId, +): { id: AnyNodeId; data: Partial }[] { + const named = new Set() + const visit = (value: unknown) => { + if (typeof value === 'string') { + if (nodes[value] && !subtreeIds.has(value) && value !== root.parentId) named.add(value) + } else if (Array.isArray(value)) value.forEach(visit) + else if (value && typeof value === 'object') Object.values(value).forEach(visit) + } + const { + id: _id, + parentId: _parentId, + children: _children, + ...fields + } = root as AnyNode & { + children?: unknown + } + visit(fields) + const ids = new Map([[root.id, committedId as string]]) + return [...named].flatMap((id) => { + const peer = nodes[id]! + const remapped = remapNodeReferences(peer, ids) as Record + const data = Object.fromEntries( + Object.entries(remapped).filter( + ([key, value]) => + JSON.stringify(value) !== JSON.stringify((peer as Record)[key]), + ), + ) + return Object.keys(data).length ? [{ id: id as AnyNodeId, data: data as Partial }] : [] + }) +} + /** * Create ops for a clone of scene nodes: each copy joins the collections its * source is in, whatever its kind. @@ -269,6 +311,9 @@ export function commitFreshPlacementSubtree( if (previous === -1) updates.push(merged) else updates[previous] = merged } + const subtreeIds = new Set([rootId, ...descendants.map((node) => node.id)]) + for (const update of peerReferenceUpdates(scene.nodes, root, subtreeIds, cloned.rootId)) + if (!updates.some((entry) => entry.id === update.id)) updates.push(update) const scope = useInteractionScope.getState() const factoryCreated = From e0cf1f74e20f4af99f52ed756f38a73edb8055b6 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Tue, 13 Oct 2026 11:32:08 -0400 Subject: [PATCH 13/16] fix: the corner-window review fixes on Bundle 2's copy; the corner-snap HUD lives in the editor This branch's review fixes to the corner-window code Bundle 2 landed: verify_scene's corner_unjoined is a note pointing at merge_windows (two windows meeting at a corner may be what the person drew); the window panel's corner choices read Fused glass and Corner post, its buttons take a token hover, and its writes go through writeWindowChanges. The corner-snap HUD state moves from core to the editor, as #1024's own list asked: it is editor UI state, not scene data. Co-authored-by: Adam NAILI Co-Authored-By: Claude Opus 5.5 --- .../src/agent-operations/corner-window.ts | 4 +++- .../core/src/building/corner-window.test.ts | 8 +++++--- .../core/src/contracts/reference-inventory.ts | 2 +- packages/core/src/index.ts | 7 ------- packages/core/src/lib/room-name.ts | 6 +++--- packages/core/src/lib/snap-ease.test.ts | 14 +++++++++++++ .../components/ui/helpers/helper-manager.tsx | 3 +-- packages/editor/src/index.tsx | 7 +++++++ .../src/store/use-corner-snap-hint.test.ts | 20 ++++++++----------- .../src/store/use-corner-snap-hint.ts | 2 +- packages/mcp/README.md | 2 +- packages/mcp/src/resources/agent-guide.ts | 2 +- packages/nodes/src/window/corner-cue.ts | 3 ++- packages/nodes/src/window/panel.tsx | 19 ++++++------------ packages/nodes/src/window/placement.ts | 16 +++++++-------- skills/pascal-3d/references/tool-workflows.md | 2 +- wiki/architecture/space-detection.md | 6 ++++++ wiki/architecture/vertical-model.md | 3 ++- 18 files changed, 69 insertions(+), 57 deletions(-) rename packages/{core => editor}/src/store/use-corner-snap-hint.test.ts (51%) rename packages/{core => editor}/src/store/use-corner-snap-hint.ts (96%) diff --git a/packages/core/src/agent-operations/corner-window.ts b/packages/core/src/agent-operations/corner-window.ts index 72a402ac7d..60e3fcd53d 100644 --- a/packages/core/src/agent-operations/corner-window.ts +++ b/packages/core/src/agent-operations/corner-window.ts @@ -71,9 +71,11 @@ export function cornerWindowIssues(nodes: Parameters[0]): Sce for (const [key, windows] of atCorner) { if (new Set(windows.map((w) => w.parentId)).size < 2) continue const corner = key.split(':')[1] + // Informational: two windows meeting at a corner may be what the person drew. issues.push({ type: 'corner_unjoined', - message: `Windows ${windows.map((w) => w.id).join(' and ')} both run to the corner (${corner}) without being joined: if the reference wraps the corner, add_corner_window builds them as one corner window, the glass fused or at a post.`, + severity: 'info', + message: `Windows ${windows.map((w) => w.id).join(' and ')} both run to the corner (${corner}) without being joined: if the reference wraps the corner, merge_windows joins them as one corner window, the glass fused.`, }) } return issues diff --git a/packages/core/src/building/corner-window.test.ts b/packages/core/src/building/corner-window.test.ts index 17790c1366..35643d0576 100644 --- a/packages/core/src/building/corner-window.test.ts +++ b/packages/core/src/building/corner-window.test.ts @@ -247,12 +247,14 @@ describe('by hand: wrap, keep in step, unwrap', () => { }) describe('verify_scene on corner windows', () => { - test('two plain windows running to the same corner are pointed at add_corner_window', () => { + test('two plain windows running to the same corner are pointed at merge_windows, as a note', () => { const { windows } = planCornerWindow(cornerScene(90), { corner: [0, 0], width: 1.2 }) const plain = windows.map(({ corner: _corner, ...rest }) => rest as WindowNode) const issues = cornerWindowIssues(apply(cornerScene(90), plain)) - expect(issues.map((issue) => issue.type)).toEqual(['corner_unjoined']) - expect(issues[0]!.message).toContain('add_corner_window') + expect(issues.map((issue) => [issue.type, issue.severity])).toEqual([ + ['corner_unjoined', 'info'], + ]) + expect(issues[0]!.message).toContain('merge_windows') }) test('a joined pair in step is quiet; drifted apart, it is named', () => { diff --git a/packages/core/src/contracts/reference-inventory.ts b/packages/core/src/contracts/reference-inventory.ts index d944f43429..7bfc138e0d 100644 --- a/packages/core/src/contracts/reference-inventory.ts +++ b/packages/core/src/contracts/reference-inventory.ts @@ -199,7 +199,7 @@ export const EXISTING_REFERENCES: readonly ExistingReference[] = [ ...policy('node', 'connection', 'drop', 'strip'), targetKinds: ['window'], remaps: ['clone-scene-graph'], - note: "A corner window's other side (L65). The whole-graph clone remaps it; a level or a selection copied otherwise renders its pair as two plain windows until wrapped again.", + note: "A corner window's other side. The whole-graph clone remaps it; a level or a selection copied otherwise renders its pair as two plain windows until wrapped again.", }), row({ kind: 'window', diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 99779a8062..025c88e24f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -601,13 +601,6 @@ export { type ScenePreview, subscribeScenePreview, } from './store/scene-preview' -export { - CORNER_BLOCK_NOTES, - type CornerHintRow, - type CornerSnapHint, - cornerHintRows, - useCornerSnapHint, -} from './store/use-corner-snap-hint' export { type ControlValue, type DoorAnimationState, diff --git a/packages/core/src/lib/room-name.ts b/packages/core/src/lib/room-name.ts index f930f75d10..7220f7cef0 100644 --- a/packages/core/src/lib/room-name.ts +++ b/packages/core/src/lib/room-name.ts @@ -1,14 +1,14 @@ const NUMBERED_ROOM = /^Room (\d+)$/ +/** A name the allocator gave, which no person has changed. */ +export const isAllocatedRoomName = (name: string | undefined) => NUMBERED_ROOM.test(name ?? '') + /** * Names rooms nobody named: "Room N" with the lowest number no zone on the level * uses yet. Only exact "Room N" names count. Called once per level, in a * deterministic room order, and only when a room zone is created, so an existing * room is never renamed and a name the user cleared stays cleared. */ -/** A name the allocator gave, which no person has changed. */ -export const isAllocatedRoomName = (name: string | undefined) => NUMBERED_ROOM.test(name ?? '') - export function roomNameAllocator(levelZones: Iterable<{ name?: string }>) { const used = new Set() for (const zone of levelZones) { diff --git a/packages/core/src/lib/snap-ease.test.ts b/packages/core/src/lib/snap-ease.test.ts index 770457f5fc..a6cf9011cd 100644 --- a/packages/core/src/lib/snap-ease.test.ts +++ b/packages/core/src/lib/snap-ease.test.ts @@ -27,6 +27,20 @@ describe('createSnapEaser', () => { expect(end).toBe(3.4) }) + test('is eased out: most of the move comes first, with no overshoot', () => { + const easer = createSnapEaser() + easer.display(0, false, 0, false) + easer.display(1, true, 0, false) + + const half = easer.display(1, true, SNAP_EASE_MS / 2, false) + const samples = [0.1, 0.3, 0.5, 0.7, 0.9, 1].map((p) => + easer.display(1, true, p * SNAP_EASE_MS, false), + ) + + expect(half).toBeGreaterThan(0.5) + for (const value of samples) expect(value).toBeLessThanOrEqual(1 + 1e-9) + }) + test('keeps following the cursor while it eases', () => { const easer = createSnapEaser() easer.display(3.0, false, 0, false) diff --git a/packages/editor/src/components/ui/helpers/helper-manager.tsx b/packages/editor/src/components/ui/helpers/helper-manager.tsx index e6475d413d..61770b4ae5 100644 --- a/packages/editor/src/components/ui/helpers/helper-manager.tsx +++ b/packages/editor/src/components/ui/helpers/helper-manager.tsx @@ -6,8 +6,6 @@ import { floorPlacedCollides, nodeRegistry, type TerrainVerb, - cornerHintRows, - useCornerSnapHint, useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' @@ -68,6 +66,7 @@ import { usePaintRegionMode, } from '../../../lib/paint-region-mode' import { snapContextOf } from '../../../lib/snapping-mode' +import { cornerHintRows, useCornerSnapHint } from '../../../store/use-corner-snap-hint' import useEditor, { getActiveContinuationContext } from '../../../store/use-editor' import useInteractionScope, { useActiveHandleDrag, diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 045f347ac2..cccc7b1bfb 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -894,6 +894,13 @@ export { CONSTRUCTION_REVEAL_LEVELS, default as useConstructionReveal, } from './store/use-construction-reveal' +export { + CORNER_BLOCK_NOTES, + type CornerHintRow, + type CornerSnapHint, + cornerHintRows, + useCornerSnapHint, +} from './store/use-corner-snap-hint' export { DRAWING_TYPE_OPTIONS, default as useDrawingView, diff --git a/packages/core/src/store/use-corner-snap-hint.test.ts b/packages/editor/src/store/use-corner-snap-hint.test.ts similarity index 51% rename from packages/core/src/store/use-corner-snap-hint.test.ts rename to packages/editor/src/store/use-corner-snap-hint.test.ts index 021feb70d7..399d788e2d 100644 --- a/packages/core/src/store/use-corner-snap-hint.test.ts +++ b/packages/editor/src/store/use-corner-snap-hint.test.ts @@ -8,21 +8,17 @@ describe('cornerHintRows', () => { expect(cornerHintRows(null)).toEqual([]) }) - test('says what a click does where the window can join, and how to skip it', () => { - const rows = cornerHintRows('join') - - expect(rows[0]?.keys).toEqual(['Left click']) - expect(rows.some((row) => row.keys.includes('Alt'))).toBe(true) - expect(cornerHintRows('new')[0]?.keys).toEqual(['Left click']) + test('says what a click does where the window can join, and how to skip it with Alt', () => { + for (const hint of ['join', 'new'] as const) + expect(cornerHintRows(hint).map((row) => row.keys)).toEqual([['Left click'], ['Alt']]) + expect(cornerHintRows('join')[0]?.label).not.toBe(cornerHintRows('new')[0]?.label) }) - test('gives each reason its own sentence where the end is no corner', () => { + test('gives each reason its own sentence where the end is no corner, and offers no click', () => { const reasons = Object.keys(CORNER_BLOCK_NOTES) as (keyof typeof CORNER_BLOCK_NOTES)[] - const labels = reasons.map((reason) => cornerHintRows(reason)[0]?.label) + const rows = reasons.map((reason) => cornerHintRows(reason)) - expect(labels.every((label) => Boolean(label))).toBe(true) - expect(new Set(labels).size).toBe(reasons.length) - // It offers no click, since a click here places a plain window. - expect(cornerHintRows('free_end').every((row) => !row.keys.includes('Left click'))).toBe(true) + expect(new Set(rows.map((row) => row[0]?.label)).size).toBe(reasons.length) + expect(rows.flat().every((row) => !row.keys.includes('Left click'))).toBe(true) }) }) diff --git a/packages/core/src/store/use-corner-snap-hint.ts b/packages/editor/src/store/use-corner-snap-hint.ts similarity index 96% rename from packages/core/src/store/use-corner-snap-hint.ts rename to packages/editor/src/store/use-corner-snap-hint.ts index ed8a37a565..4edfa8c733 100644 --- a/packages/core/src/store/use-corner-snap-hint.ts +++ b/packages/editor/src/store/use-corner-snap-hint.ts @@ -1,5 +1,5 @@ +import type { CornerBlockReason } from '@pascal-app/core' import { create } from 'zustand' -import type { CornerBlockReason } from '../lib/corner-snap' /** * What the window being placed or dragged near a wall end says: `join` when a window already waits diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 62d92751c0..79f3aec7e3 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -373,7 +373,7 @@ an optional `height` and uses the same default. | `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` | | `place_items` | Place catalog items in one call, each placed or refused on its own, at level `(x, z)`: on the level's floor, or on the host each names (`targetNodeId`): a wall (`y`, the height of its bottom, required), a ceiling, or an item standing on the floor. A floor item in a door's clearance is refused `blocks_door` (naming the door and a spot that clears every door), one its room cannot hold in any turn `too_large_for_room`. | `{ items: [{ assetId, x, z, y?, targetNodeId?, rotation? }], levelId? }` | `{ levelId, items: [{ ok, itemId?, hostId?, restingOn?, code? }] }`; per-item refusals `asset_not_found`, `outside_rooms`, `blocks_door`, `too_large_for_room`, `height_required`, `item_too_tall`, `unsupported_host`, `host_not_found`, `host_not_on_level` | | `place_design` | Create one design (procedural item recipe, object or JSON string) that passes `validate_design`. Its mounting picks the host: level/slab/zone or a design surface (`surfaceId`), a straight wall face, or a ceiling. Create-only, one undo step, inline designs up to 24 KiB, with coded refusals. | `{ design, hostId, position, rotation?, side?, surfaceId?, parameters?, slots?, name?, id? }` | `{ designId, parentId, surfaceId }` | -| `set_zone` | Create a zone/room polygon on a level. | `{ levelId, polygon, label, properties? }` | `{ zoneId }` | +| `set_zone` | Create a zone/room polygon on a level; over walls that already enclose a room still named "Room N", it names that room instead of adding a zone. | `{ levelId, polygon, label, properties? }` | `{ zoneId }` | | `duplicate_level` | Copy a level as the editor does (units whose rooms are all on it included; plan references, scans and spawns left behind), above or below, shifting the floors past it. | `{ levelId, position?, name?, preset? }` | `{ newLevelId, name, floorIndex, shiftedLevelIds, copied, skipped, newNodeIds[] }` | | `delete_node` | Delete a node with everything under it, as the editor's Delete does. | `{ id }` | `{ deletedIds: [] }` | | `clear_scene` | Empty the project on purpose, back to the default site, building and level. The only way to empty a project: a write that would leave it empty is refused (`scene_wipe_blocked`) by a store that guards against accidental wipes, and the session keeps the project as stored. | `{ reason }` | `{ cleared: { removed }, version, graphHash }` | diff --git a/packages/mcp/src/resources/agent-guide.ts b/packages/mcp/src/resources/agent-guide.ts index db3a641c04..aa0ddb01b4 100644 --- a/packages/mcp/src/resources/agent-guide.ts +++ b/packages/mcp/src/resources/agent-guide.ts @@ -39,7 +39,7 @@ export const AGENT_GUIDE = [ '- When you do use `apply_patch` for bulk graph edits, batch-first is the default: one call containing all create/update/delete ops for the phase, in stable order so later ops can reference ids created earlier. A single call is atomic (all or nothing); do not loop one-op `apply_patch` calls.', '- For rooms, use `create_room` -> `add_door` -> `add_window` -> `furnish_room`.', '- Where walls already enclose a room, `create_room` names that room instead of adding a second one. A room with a name of its own (not a number the editor gave it) is refused (`room_named_by_person`): pass `rename: true` only when the person asked for the rename, otherwise ask them; it is never renamed silently. Drawing a room by hand behaves as before.', - "- Where a reference's glass meets at a corner, with no wall between the panes, that is one corner window: `add_corner_window` builds both sides, the glass fused or at a post. Two separate windows near the corner leave it solid.", + "- Where a reference's glass meets at a corner, with no wall between the panes, that is one corner window: `add_corner_window` builds both sides, the glass fused or at a post. Two separate windows near the corner leave it solid; `merge_windows` joins two that exist.", "- Give finishes with `paint`, as the editor's Paint does: a library material found with `search_materials`, a colour, or a native material for an exact finish. Do not write `slots` or material presets through `apply_patch`.", '- Floors and ceilings are DERIVED from rooms, never authored. State intent — walls, separators and a zone with `spaceRole: "room"` — and the structure reconciler builds the floor plate and the ceiling for you. Creating a slab or ceiling carrying the derived markers (`boundary` / `autoFromWalls`), or reshaping or relinking one, is refused; deleting one records that opt-out on the zone instead. A room without a floor or ceiling is expressed on its zone (`hasFloor: false`, `hasCeiling: false`), and its floor height with `floor.elevation`.', '- Manual slabs remain valid for things that are not rooms: pools, platforms, stair decks, terraces at their own elevation.', diff --git a/packages/nodes/src/window/corner-cue.ts b/packages/nodes/src/window/corner-cue.ts index 12629eb49a..dd0941edb0 100644 --- a/packages/nodes/src/window/corner-cue.ts +++ b/packages/nodes/src/window/corner-cue.ts @@ -1,4 +1,5 @@ -import { type CornerBlock, type CornerSnap, useCornerSnapHint } from '@pascal-app/core' +import type { CornerBlock, CornerSnap } from '@pascal-app/core' +import { useCornerSnapHint } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' // What a window placed or dragged near a corner shows: the HUD hint says what a click will do, and diff --git a/packages/nodes/src/window/panel.tsx b/packages/nodes/src/window/panel.tsx index 6085bc068f..a9473370c3 100644 --- a/packages/nodes/src/window/panel.tsx +++ b/packages/nodes/src/window/panel.tsx @@ -171,15 +171,8 @@ export default function WindowPanel() { : cornerState === 'wrappable' ? { paired: false as const } : null - const writeWindows = (updates: Record>) => { - const scene = useScene.getState() - for (const [id, update] of Object.entries(updates)) { - scene.updateNode(id as AnyNodeId, update) - scene.dirtyNodes.add(id as AnyNodeId) - const parentId = scene.nodes[id as AnyNodeId]?.parentId - if (parentId) scene.dirtyNodes.add(parentId as AnyNodeId) - } - } + const writeWindows = (updates: Record>) => + writeWindowChanges({ create: null, updates, remove: [] }) const wrapTheCorner = () => { if (!selectedId) return const { create, updates } = wrapCorner(useScene.getState().nodes, selectedId) @@ -529,7 +522,7 @@ export default function WindowPanel() { Wraps the corner with {corner.partner.name || 'its partner'}
) : (