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__/cases.ts b/packages/core/src/agent-operations/__fixtures__/cases.ts index 1b730dd217..db930ece8e 100644 --- a/packages/core/src/agent-operations/__fixtures__/cases.ts +++ b/packages/core/src/agent-operations/__fixtures__/cases.ts @@ -20,11 +20,17 @@ 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' 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 +900,8 @@ export const AGENT_TOOL_CASES: readonly AgentToolCase[] = [ ...FURNISH_ROOM_CASES, ...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__/corner-window-cases.ts b/packages/core/src/agent-operations/__fixtures__/corner-window-cases.ts index ec7e4d7c57..08e1c533e6 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. */ @@ -27,6 +27,34 @@ function cornerScene(degrees: number): SceneGraph { } } +/** Two storeys of the same square corner, stacked: their walls end at the same plan points. */ +function stackedScene(): SceneGraph { + const ground = cornerScene(90) + const walls = ['wall_ca', 'wall_cb'].map((id) => + WallNode.parse({ + ...(ground.nodes[id] as WallNode), + id: `${id}_up`, + parentId: 'level_u', + }), + ) + const upper = LevelNode.parse({ + id: 'level_u', + parentId: 'building_c', + level: 1, + children: walls.map((wall) => wall.id), + }) + const building = BuildingNode.parse({ id: 'building_c', children: ['level_c', upper.id] }) + return { + nodes: { + ...ground.nodes, + building_c: building, + [upper.id]: upper, + ...Object.fromEntries(walls.map((wall) => [wall.id, wall])), + }, + rootNodeIds: ground.rootNodeIds, + } +} + const joined = (result: Record, nodes: Readonly>) => { const windows = (result.windowIds as string[]).map((id) => nodes[id] as WindowNode | undefined) const [a, b] = windows @@ -88,4 +116,49 @@ export const CORNER_WINDOW_CASES: AgentToolCase[] = [ input: { corner: [0, 0], width: 1, windowType: 'sliding', style: 'double-hung' }, expect: { refusal: 'style_needs_fixed_window', mentions: ["'fixed'", 'sliding'] }, }, + // Stacked storeys share their corners' points: with no level named the corner was ambiguous on + // every multi-storey house, and two walls named from two floors made one window across both. + { + name: 'on a two-storey house, the corner of the level named', + tool: 'add_corner_window', + scene: stackedScene, + input: { corner: [0, 0], width: 1.2, levelId: 'level_u' }, + expect: { + result: { ok: true, wallIds: ['wall_ca_up', 'wall_cb_up'] }, + check: joined, + }, + }, + { + name: 'on a two-storey house, with no level named, the corner of the floor in view', + tool: 'add_corner_window', + scene: stackedScene, + // The MCP has no floor in view. + surfaces: ['core'], + context: { activeLevelId: 'level_u' }, + input: { corner: [0, 0], width: 1.2 }, + expect: { result: { ok: true, wallIds: ['wall_ca_up', 'wall_cb_up'] } }, + }, + { + name: 'two walls named from two floors are no corner', + tool: 'add_corner_window', + scene: stackedScene, + context: { activeLevelId: 'level_c' }, + input: { corner: [0, 0], width: 1.2, wallIds: ['wall_ca', 'wall_cb_up'] }, + expect: { refusal: 'no_corner', mentions: ['level_c'] }, + }, + // A casement keeps its frames at the corner: "fused glass" was offered, and drawn as frames. + { + name: 'a casement corner window meets at a post', + tool: 'add_corner_window', + scene: () => cornerScene(90), + input: { corner: [0, 0], width: 1, windowType: 'casement' }, + expect: { result: { ok: true, post: 'post' }, check: joined }, + }, + { + name: 'fused glass on a casement corner window is refused', + tool: 'add_corner_window', + scene: () => cornerScene(90), + input: { corner: [0, 0], width: 1, windowType: 'casement', post: 'none' }, + expect: { refusal: 'corner_not_fused', mentions: ['casement', 'fixed'] }, + }, ] 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..50dc198dc3 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 = { @@ -282,7 +282,8 @@ export const MERGE_WINDOWS_CASES: AgentToolCase[] = [ tool: 'merge_windows', scene: () => corner(90, [ - { ...nearA, extra: { corner: { end: 'end', partnerId: 'window_c', post: 'none' } } }, + // At the corner, joined to window_c: a pair that holds. + { ...nearA, x: 3.4, extra: { corner: { end: 'end', partnerId: 'window_c', post: 'none' } } }, nearB, { id: 'window_c', @@ -297,6 +298,58 @@ export const MERGE_WINDOWS_CASES: AgentToolCase[] = [ input: { windowIds: ['window_a', 'window_b'] }, expect: { refusal: 'window_already_joined', mentions: ['window_a', 'window_c'] }, }, + // A window dragged off its corner left both sides naming each other: the old pair held the window + // hostage ("unwrap it first") and its partner kept a link to a window gone elsewhere. + { + name: "a window whose old pair no longer holds joins, and the old partner's link is cleared", + tool: 'merge_windows', + scene: () => + corner(90, [ + // 0.10 m short of the corner: no longer one side of its pair with window_c. + { ...nearA, extra: { corner: { end: 'end', partnerId: 'window_c', post: 'none' } } }, + nearB, + { + id: 'window_c', + wall: 'wall_cb', + x: 2.2, + width: 0.6, + sill: 0.5, + height: 1.5, + extra: { corner: { end: 'start', partnerId: 'window_a', post: 'none' } }, + }, + ]), + input: { windowIds: ['window_a', 'window_b'] }, + expect: { + result: { ok: true, kind: 'corner', windowIds: ['window_a', 'window_b'] }, + check: (_result, nodes) => { + const [a, c] = [nodes.window_a, nodes.window_c] as WindowNode[] + return [ + ...(a?.corner?.partnerId === 'window_b' ? [] : [`window_a joined to ${a?.corner?.partnerId}`]), + ...(c?.corner ? [`window_c still names ${c.corner.partnerId}`] : []), + ] + }, + }, + }, + { + name: 'a window inside another on one wall merges into their union, not a shrunk window', + tool: 'merge_windows', + scene: () => + scene( + [wall5], + [ + { id: 'window_l', wall: 'wall_m', x: 2.5, width: 4, sill: 0.5, height: 1.5 }, + { id: 'window_r', wall: 'wall_m', x: 3.5, width: 1, sill: 0.5, height: 1.5 }, + ], + ), + input: { windowIds: ['window_l', 'window_r'] }, + expect: { + result: { ok: true, kind: 'same_wall', width: 4 }, + check: (_result, nodes) => { + const merged = nodes.window_l as WindowNode + return merged?.position[0] === 2.5 ? [] : [`centre ${merged?.position[0]}`] + }, + }, + }, { name: 'the same window twice is refused', tool: 'merge_windows', 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..5a2ece11eb --- /dev/null +++ b/packages/core/src/agent-operations/__fixtures__/roof-cases.ts @@ -0,0 +1,342 @@ +import { + type AnyNode, + BuildingNode, + getActiveRoofHeight, + LevelNode, + RoofNode, + RoofSegmentNode, + SlabNode, + 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 (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: (result, nodes) => { + const level = nodes[result.roofLevelId as string] as LevelNode | undefined + const rise = (result.segmentIds as string[]).map((id) => + getActiveRoofHeight(nodes[id] as RoofSegmentNode), + ) + // The new roof level is as tall as the roof rises over it, the eave's lift included. + return [ + ...onRoofLevel('level_g', 0.5)(result, nodes), + ...(Math.abs((level?.height ?? 0) - (0.5 + Math.max(...rise))) < 1e-6 + ? [] + : [`the roof level is ${level?.height} m tall, under its roof`]), + ] + }, + }, + }, + { + 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: '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 rectangle on a site of two empty buildings, with no level named: which one is asked', + 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 }), + BuildingNode.parse({ id: 'building_o', children: ['level_o'] }), + LevelNode.parse({ id: 'level_o', parentId: 'building_o', level: 0, height: 2.5 }), + ), + input: { width: 4, depth: 3 }, + context: { activeLevelId: null }, + expect: { refusal: 'building_required', mentions: ['building_r', 'building_o'] }, + }, + { + name: 'a fascia finish the library lacks is refused, as the roof finish is', + tool: 'create_roof', + scene: house(), + input: { fasciaMaterialPreset: 'library:no-such-fascia' }, + expect: { refusal: 'unknown_material', mentions: ['fasciaMaterialPreset'] }, + }, + { + name: "a two-storey house with a porch roof drawn on the upper storey still takes its own roof", + tool: 'create_roof', + scene: () => { + const two = house({ storeys: 2 })() + const porch = RoofNode.parse({ id: 'roof_porch', parentId: 'level_1', position: [10, 0, 3] }) + const upper = two.nodes.level_1 as LevelNode + return graph( + ...(Object.values(two.nodes) as AnyNode[]).filter((node) => node.id !== 'level_1'), + { ...upper, children: [...upper.children, porch.id] }, + porch, + ) + }, + input: {}, + expect: { result: { coveredLevelId: '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 70cfcaab1c..02d66344da 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', @@ -182,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..425ebadf06 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,46 @@ 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 below an empty storey, a floor still to be built, is reported', + () => + scene( + building(), + level('level_0', 0), + level('level_roof', 1, { metadata: { role: 'roof' } }), + level('level_2', 2), + ...room('level_0', 'ground'), + 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', () => @@ -287,6 +327,12 @@ export const VERIFY_SCENE_CASES: AgentToolCase[] = [ mentions: ['dedicated roof level'], }, ), + verify( + "a lower wing's roof at the upper storey's floor, where the Roof tool puts it, is quiet", + () => + twoStoreys(...stairOn('level_0'), RoofNode.parse({ id: 'roof_porch', parentId: 'level_1' })), + { lacks: ['roof_mixed_in_storey'] }, + ), verify( 'a wall taller than its storey in a multi-storey building is reported', () => diff --git a/packages/core/src/agent-operations/achieved.test.ts b/packages/core/src/agent-operations/achieved.test.ts index b4911049db..9f48521bce 100644 --- a/packages/core/src/agent-operations/achieved.test.ts +++ b/packages/core/src/agent-operations/achieved.test.ts @@ -43,6 +43,21 @@ describe('what a call achieved', () => { }) }) + test('counts a node updated twice once, and an update to what it holds as no change', () => { + const nodes = scene() + expect( + achievedChanges(nodes, { + update: [ + { id: 'wall_a', data: { height: 3 } }, + { id: 'wall_a', data: { thickness: 0.3 } }, + ], + }).updated, + ).toBe(1) + expect( + achievedChanges(nodes, { update: [{ id: 'wall_a', data: { end: [4, 0] } }] }), + ).toMatchObject({ updated: 0, unchanged: true }) + }) + test('says when a call changed nothing', () => { expect(achievedChanges(scene(), { update: [{ id: 'wall_missing', data: {} }] })).toEqual({ created: {}, diff --git a/packages/core/src/agent-operations/achieved.ts b/packages/core/src/agent-operations/achieved.ts index 87e2dd425c..de5b560ef2 100644 --- a/packages/core/src/agent-operations/achieved.ts +++ b/packages/core/src/agent-operations/achieved.ts @@ -28,8 +28,14 @@ export function achievedChanges(before: SceneNodes, changes: SceneChanges): Achi const after = applySceneChanges(before as never, changes) as SceneNodes const created = Object.keys(after).filter((id) => !(id in before)) const deleted = Object.keys(before).filter((id) => !(id in after)) - const updated = (changes.update ?? []).filter( - ({ id }) => id in before && id in after && before[id] !== after[id], + // Once per node, and only when its data changed: an update that writes what a node already + // holds is no change. + const updated = [...new Set((changes.update ?? []).map(({ id }) => id))].filter( + (id) => + id in before && + id in after && + before[id] !== after[id] && + JSON.stringify(before[id]) !== JSON.stringify(after[id]), ).length return { created: countByType(created, after), 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/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/corner-window.ts b/packages/core/src/agent-operations/corner-window.ts index d0e4d02a08..28f42158c2 100644 --- a/packages/core/src/agent-operations/corner-window.ts +++ b/packages/core/src/agent-operations/corner-window.ts @@ -1,15 +1,20 @@ -import { planCornerWindow } from '../building/corner-window' +import { type CornerWindowInput, planCornerWindow } from '../building/corner-window' import { cornerPair, wallEndPoint, windowCornerEnd } from '../lib/corner-pair' import type { WallNode, WindowNode } from '../schema' import { achievedChanges } from './achieved' +import { type LevelTargetInput, targetLevel } from './level-target' import type { AgentOperation, SceneChanges } from './types' import { registerSceneCheck, type SceneIssue } from './verify-scene' -type AddCornerWindowInput = Parameters[1] +type AddCornerWindowInput = Omit & LevelTargetInput -/** `add_corner_window` (L65): 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) +/** + * `add_corner_window`: one window on each of the two walls meeting at a corner, joined, on the level + * named, else the one in view: stacked storeys share their corners' plan points. + */ +export const addCornerWindow: AgentOperation = (nodes, input, context) => { + const level = targetLevel(nodes, input, context) + const { windows, wallIds, angle } = planCornerWindow(nodes, { ...input, levelId: level.id }) const changes: SceneChanges = { create: windows.map((node) => ({ node: node as never, parentId: node.parentId! })), } @@ -30,9 +35,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[] = [] @@ -41,10 +46,12 @@ export function cornerWindowIssues(nodes: Parameters[0]): Sce if (node.type !== 'window') continue const window = node as WindowNode const pair = cornerPair(nodes, window) + // Informational: what broke the pair (a side deleted or moved, a wall moved) was a choice. if (window.corner && !pair) { issues.push({ type: 'corner_partner_missing', - message: `Window ${window.id} names corner partner ${window.corner.partnerId}, but one of them is gone, no longer joined back or no longer at the corner: it renders as a plain window. Rebuild the pair with add_corner_window.`, + severity: 'info', + message: `Window ${window.id} names corner partner ${window.corner.partnerId}, but they no longer make a corner window (one is gone or moved off the corner, or the walls no longer meet there): it renders as a plain window. Only if the person wants the corner wrapped again, merge_windows joins two windows there.`, }) continue } @@ -71,9 +78,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/agent-operations/create-roof.test.ts b/packages/core/src/agent-operations/create-roof.test.ts index 7ae497ba5e..63ef1bfb39 100644 --- a/packages/core/src/agent-operations/create-roof.test.ts +++ b/packages/core/src/agent-operations/create-roof.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test } from 'bun:test' import { type AgentRefusal, isAgentRefusal } from '../agent-tools/refusal' import { BuildingNode, LevelNode, WallNode } from '../schema' -import { createRoof, grown, type RoofHost, roofHost } from './create-roof' +import { planRoofLevel, type RoofLevelPlan } from '../services/roof-level' +import { createRoof, grown } from './create-roof' import type { SceneNodes } from './types' type Pt = [number, number] @@ -69,8 +70,9 @@ describe('create_roof: the level above the covered storey', () => { const created = (outcome: ReturnType) => (outcome.changes?.create ?? []).map(({ node }) => node) - // add_level always leaves a new storey empty: a person or an agent is about to build on it. - test('a storey added and not built yet is not the roof level: the roof goes above it', () => { + // The empty level directly above, with nothing over it, is where the roof goes: declared the + // roof level, so a floor added later goes under it. + test('an empty level directly above, the only one, becomes the roof level', () => { const empty = LevelNode.parse({ id: 'level_added', parentId: 'building_a', @@ -79,16 +81,11 @@ describe('create_roof: the level above the covered storey', () => { }) const outcome = createRoof(house([empty]), {}, { activeLevelId: null }) - const [roofLevel] = created(outcome).filter((node) => node.type === 'level') - expect(roofLevel).toMatchObject({ - type: 'level', - level: 2, - parentId: 'building_a', - metadata: { role: 'roof' }, - }) - const roof = created(outcome).find((node) => node.type === 'roof') - expect(roof?.parentId).toBe(roofLevel?.id) - expect(roof?.parentId).not.toBe(empty.id) + expect(created(outcome).filter((node) => node.type === 'level')).toHaveLength(0) + expect(created(outcome).find((node) => node.type === 'roof')?.parentId).toBe(empty.id) + expect(outcome.changes?.update).toEqual([ + { id: empty.id, data: { metadata: { role: 'roof' }, name: 'Roof' } }, + ]) }) test('a roof level already above the storey is the roof level', () => { @@ -157,9 +154,9 @@ describe('create_roof: the level above the covered storey', () => { }) }) -// The host rule is one function and a table: each row is a house of one storey (level 0, 8 × 6 m) -// with the levels listed above it, and what the roof is to stand on. -describe('roofHost: the level a roof stands on', () => { +// The rule is one function and a table: each row is a house of one storey (level 0, 8 × 6 m) with +// the levels listed above it, and what the roof is to stand on. +describe('planRoofLevel: the level a roof stands on', () => { type Spec = { id: string level: number @@ -206,19 +203,18 @@ describe('roofHost: the level a roof stands on', () => { ...Object.fromEntries([...levels, ...walls].map((node) => [node.id, node])), } as SceneNodes } - const hostOf = (above: Spec[]): RoofHost => - roofHost(scene(above), scene(above).level_0 as Parameters[1]) + const hostOf = (above: Spec[]): RoofLevelPlan => planRoofLevel(scene(above), 'level_0', 3) const rows: { name: string; above: Spec[]; host: Record }[] = [ { name: 'no level above: a roof level is built above the storey', above: [], - host: { kind: 'create', above: { id: 'level_0' } }, + host: { kind: 'roof', create: { level: 1, metadata: { role: 'roof' } } }, }, { name: 'a roof level above: it is the host', above: [{ id: 'level_roof', level: 1, role: 'roof' }], - host: { kind: 'reuse', level: { id: 'level_roof' } }, + host: { kind: 'roof', levelId: 'level_roof', create: null }, }, { name: 'two roof levels above: the lowest is the host', @@ -226,22 +222,22 @@ describe('roofHost: the level a roof stands on', () => { { id: 'level_roof', level: 1, role: 'roof' }, { id: 'level_roof_2', level: 2, role: 'roof' }, ], - host: { kind: 'reuse', level: { id: 'level_roof' } }, + host: { kind: 'roof', levelId: 'level_roof', create: null }, }, { name: 'a support level above, with nothing on it: not the host, the roof goes above it', above: [{ id: 'level_support', level: 1, role: 'support' }], - host: { kind: 'create', above: { id: 'level_support' } }, + host: { kind: 'roof', create: { level: 2 } }, }, { name: 'a support level above, with things on it: not the host either', above: [{ id: 'level_support', level: 1, role: 'support', rooms: true }], - host: { kind: 'create', above: { id: 'level_support' } }, + host: { kind: 'roof', create: { level: 2 } }, }, { - name: 'an empty occupied storey above: a floor to be built, the roof goes above it', + name: 'an empty storey above, alone: it becomes the roof level', above: [{ id: 'level_added', level: 1 }], - host: { kind: 'create', above: { id: 'level_added' } }, + host: { kind: 'roof', levelId: 'level_added', create: null }, }, { name: 'several empty storeys and a support level stacked: the roof goes above the highest', @@ -250,7 +246,7 @@ describe('roofHost: the level a roof stands on', () => { { id: 'level_added_2', level: 2 }, { id: 'level_support', level: 3, role: 'support' }, ], - host: { kind: 'create', above: { id: 'level_support' } }, + host: { kind: 'roof', create: { level: 4 } }, }, { name: 'a roof level above several floors to be built is the host', @@ -259,7 +255,7 @@ describe('roofHost: the level a roof stands on', () => { { id: 'level_support', level: 2, role: 'support' }, { id: 'level_roof', level: 3, role: 'roof' }, ], - host: { kind: 'reuse', level: { id: 'level_roof' } }, + host: { kind: 'roof', levelId: 'level_roof', create: null }, }, { name: 'a roof level below a floor to be built is not above it: a new roof level goes above', @@ -267,25 +263,25 @@ describe('roofHost: the level a roof stands on', () => { { id: 'level_roof', level: 1, role: 'roof' }, { id: 'level_added', level: 2 }, ], - host: { kind: 'create', above: { id: 'level_added' } }, + host: { kind: 'roof', create: { level: 3 } }, }, { - name: 'a storey with rooms above: refused, naming it', + name: 'a storey with rooms above: it takes the roof (a lower wing), and create_roof refuses', above: [{ id: 'level_upper', level: 1, rooms: true }], - host: { kind: 'refuse', code: 'storey_above', storeyAbove: { id: 'level_upper' } }, + host: { kind: 'storey', levelId: 'level_upper' }, }, { - name: 'a storey with rooms above a roof level: refused, the roof level does not hide it', + name: 'a storey with rooms above a roof level: the roof level does not hide it', above: [ { id: 'level_roof', level: 1, role: 'roof' }, { id: 'level_upper', level: 2, rooms: true }, ], - host: { kind: 'refuse', code: 'storey_above', storeyAbove: { id: 'level_upper' } }, + host: { kind: 'storey', levelId: 'level_upper' }, }, { name: 'a roof level of another building is not reused', above: [{ id: 'level_other_roof', level: 1, role: 'roof', building: 'building_b' }], - host: { kind: 'create', above: { id: 'level_0' } }, + host: { kind: 'roof', create: { level: 1 } }, }, ] @@ -301,12 +297,8 @@ describe('roofHost: the level a roof stands on', () => { [8, 6], [0, 6], ]) - const orphan = { ...base.level_0, parentId: undefined } as unknown as Parameters< - typeof roofHost - >[1] - const nodes = { ...base, level_0: orphan } as SceneNodes + const nodes = { ...base, level_0: { ...base.level_0, parentId: undefined } } as SceneNodes - expect(roofHost(nodes, orphan)).toEqual({ kind: 'refuse', code: 'storey_without_building' }) expect(() => createRoof(nodes, {}, { activeLevelId: null })).toThrow(/not in a building/) }) diff --git a/packages/core/src/agent-operations/create-roof.ts b/packages/core/src/agent-operations/create-roof.ts index b0674f019d..cecf6b4cd7 100644 --- a/packages/core/src/agent-operations/create-roof.ts +++ b/packages/core/src/agent-operations/create-roof.ts @@ -3,13 +3,16 @@ 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 +39,9 @@ type CreateRoofInput = { name?: string } -/** A storey a roof can cover: rooms, or walls whose outline closes once their gaps are bridged. */ +/** A storey a roof can cover: one with a footprint (`footprintPolygons`). */ const hasFootprint = (nodes: SceneNodes, levelId: string) => - Object.values(nodes).some((node) => node.type === 'zone' && node.parentId === levelId) || - wallOutline(nodes, levelId).length > 0 + footprintPolygons(nodes, levelId).length > 0 const buildingLevels = (nodes: SceneNodes, level: Level) => levelsOf(nodes).filter((other) => other.parentId === level.parentId) @@ -54,11 +56,15 @@ function fasciaFields({ fasciaHeight, fasciaMaterialPreset }: CreateRoofInput) { } } +/** A level a roof can cap: not a roof level, not a support level. */ +const isStorey = (nodes: SceneNodes, level: Level) => levelRole(nodes, level).role === 'occupied' + /** - * 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,19 +81,37 @@ 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 || hasFootprint(nodes, requested.id))) + 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 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) => hasFootprint(nodes, candidate.id)) + // With no level in view (the MCP), the last level by ordinal would pick a building by its id; + // a rectangle with no level holding anything falls back to every candidate, so asks of those. + const buildingIds = [ + ...new Set( + (rectangle && !eligible.length ? candidates : 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) => 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 = 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) => hasFootprint(nodes, candidate.id)).at(-1) + const storey = eligible.at(-1) if (!storey) refuse( 'no_rooms', @@ -96,9 +120,10 @@ function coveredStorey( ) 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 or closed walls; the roof covers ${storey.name ?? storey.id} (${storey.id}), the highest level with rooms.`, + }), } } @@ -177,10 +202,12 @@ export function grown(polygon: Pt[], distance: number): Pt[] { 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. + * A storey's footprint: its walls' outline, every gap bridged, else its floor plates (a balcony's + * left out; overlapping plates are one footprint), else its rooms. */ -function footprintOf(nodes: SceneNodes, storeyId: string) { +function footprintPolygons(nodes: SceneNodes, storeyId: string): Pt[][] { + const outline = wallOutline(nodes, storeyId) + if (outline.length) return outline const on = (type: 'slab' | 'zone') => Object.values(nodes).filter( (node) => @@ -193,57 +220,23 @@ function footprintOf(nodes: SceneNodes, storeyId: string) { ), ) 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 { rectangles, roofType } = roofRectangles(frame.polygons) - return { rectangles, roofType, angle: frame.angle, toWorld: frame.toWorld } + return (plates.length ? plates : on('zone')).map((node) => node.polygon) } -/** 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 } +/** Rectangles in the storey's own frame and the turn back to the world, from its footprint. */ +function footprintOf(nodes: SceneNodes, storeyId: string) { + const frame = buildingFrame(footprintPolygons(nodes, storeyId)) + const { rectangles, roofType } = roofRectangles(frame.polygons) + return { rectangles, roofType, angle: frame.angle, toWorld: frame.toWorld } } /** * `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 @@ -265,22 +258,19 @@ 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 }, - ) - } + if (!storey.parentId) 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 }, ) - } + const existing = roofsOver(nodes, storey.id) + if (existing.length) + refuse( + '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 @@ -334,48 +324,64 @@ export const createRoof: AgentOperation = (nodes, input, contex }) }) - 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}): 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 }, + ) + } + 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] } + // A roof level made for it is as tall as the roof rises over it, the eave's lift included. + if (plan.create) + changes.create[0] = { + node: { ...plan.create, height: Math.max(elevation + wallHeight + peak, 0.2) }, + parentId: plan.create.parentId ?? undefined, + } + changes.create.push({ node: roof, parentId: plan.levelId }, ...draft.slice(1)) return { result: { + ok: true, 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 4f644b44d8..e7ae7d3be6 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' @@ -35,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' @@ -82,6 +85,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-operations/level-height-fill.test.ts b/packages/core/src/agent-operations/level-height-fill.test.ts index c0864a0f0a..9c0b05fd87 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) => { @@ -56,6 +56,20 @@ describe('a level written without a height', () => { ]) }) + // apply_patch's wire format names a create's parent beside the node: the walls read as missing, + // so the level was saved at the default height under 3 m walls. + test('created with walls that name it only by the create’s own parentId', () => { + const walls = tallWalls('level_up').map(({ parentId: _parentId, ...wall }) => wall as AnyNode) + const ops: LevelHeightWrite[] = [ + { op: 'create', node: heightless('level_up') }, + ...walls.map((node) => ({ op: 'create' as const, node, parentId: 'level_up' })), + ] + fillLevelHeights(ops, scene) + expect((ops[0] as { node: { height?: number } }).node.height).toBe( + editorHeight('level_up', tallWalls('level_up'))!, + ) + }) + test('created empty, gets the default storey height', () => { const ops: LevelHeightWrite[] = [{ op: 'create', node: heightless('level_empty') }] expect(fillLevelHeights(ops, scene)).toEqual([ diff --git a/packages/core/src/agent-operations/level-height-fill.ts b/packages/core/src/agent-operations/level-height-fill.ts index 969431bd1f..a9538eaee0 100644 --- a/packages/core/src/agent-operations/level-height-fill.ts +++ b/packages/core/src/agent-operations/level-height-fill.ts @@ -3,19 +3,19 @@ import { deriveLegacyLevelHeight } from '../services/level-height' /** A write's operations, as apply_patch and update_node carry them. */ export type LevelHeightWrite = - | { op: 'create'; node: AnyNode } + | { op: 'create'; node: AnyNode; parentId?: string } | { op: 'update'; id: string; data: Record } | { op: 'delete'; id: string } 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[], @@ -23,7 +23,9 @@ export function fillLevelHeights( ): string[] { const after: Record = { ...scene } for (const op of ops) { - if (op.op === 'create') after[op.node.id] = op.node + // A create's own parentId wins over its node's, as the write applies it. + if (op.op === 'create') + after[op.node.id] = op.parentId ? ({ ...op.node, parentId: op.parentId } as AnyNode) : op.node else if (op.op === 'delete') delete after[op.id] else if (after[op.id]) after[op.id] = { ...after[op.id], ...op.data } as AnyNode } diff --git a/packages/core/src/agent-operations/roof-footprint.ts b/packages/core/src/agent-operations/roof-footprint.ts index 3e7ab59e3d..591451b558 100644 --- a/packages/core/src/agent-operations/roof-footprint.ts +++ b/packages/core/src/agent-operations/roof-footprint.ts @@ -1,11 +1,9 @@ // 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 +// slices the footprint into hundreds of slivers, 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 } @@ -21,17 +19,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. @@ -260,8 +261,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 + * The frame a roof is swept in: the footprint's own axes, from its longest edge, so a rotated + * building gets whole wings, not a staircase of segments along the world axes. 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[])[]) { diff --git a/packages/core/src/agent-operations/scene-queries.ts b/packages/core/src/agent-operations/scene-queries.ts index 942bd465df..ebdf97f12b 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 { servesAsRoofLevel } from '../services/roof-level' import type { SceneNodes } from './types' export type LevelRole = 'occupied' | 'roof' | 'support' @@ -74,26 +75,17 @@ 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' - const occupied = - counts.walls + - counts.zones + - counts.doors + - counts.windows + - counts.items + - counts.slabs + - counts.ceilings + - counts.stairs - if (counts.roofs > 0 && occupied === 0) return 'roof' - return 'occupied' -} - +/** + * A level is a storey unless it serves as its building's roof level (`servesAsRoofLevel`, the one + * rule) or is declared a support. + */ export function levelRole(nodes: SceneNodes, level: AnyNode) { - const role = classifyLevel(level, contentCounts(nodesOnLevel(nodes, level.id))) + const role: LevelRole = + level.type === 'level' && servesAsRoofLevel(nodes, level) + ? 'roof' + : metadataString(level, 'role') === 'support' + ? 'support' + : 'occupied' return { role, metadataRole: metadataString(level, 'role') ?? null, diff --git a/packages/core/src/agent-operations/verify-scene.ts b/packages/core/src/agent-operations/verify-scene.ts index f82af58ae4..be6446c2cf 100644 --- a/packages/core/src/agent-operations/verify-scene.ts +++ b/packages/core/src/agent-operations/verify-scene.ts @@ -4,6 +4,7 @@ import { wallSupportForNodes } from '../lib/opening-floor-datum' import { detectOpenWallEnds, type OpenWallEnd } from '../lib/room-graph' import { nodeRegistry } from '../registry' import { type AnyNode, type AnyNodeId, AnyNode as AnyNodeSchema } from '../schema' +import { roofsOver } from '../services/roof-level' import { getStoredLevelHeight, getWallCoveringSlabs, getWallTop } from '../services/storey' import { computeSegmentTransforms, rotateXZ } from '../systems/stair/stair-footprint' import { resolveStairTotalRise } from '../systems/stair/stair-rise-query' @@ -264,6 +265,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 +307,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) + // An empty storey counts: it is a floor still to be built, and the roof goes above it. + const storeyAbove = levels.find( + (other) => + other.isOccupiedStory && + 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 } @@ -317,7 +329,12 @@ export const verifyScene: AgentOperation = ( report('zones_no_ceilings', `${levelName} has zones but no ceilings`) if (content.walls > 0 && content.doors === 0) report('walls_no_doors', `${levelName} has walls but no doors`) - if (content.roofs > 0 && (content.walls > 0 || content.zones > 0 || content.stairs > 0)) + // A roof on a storey that covers it (written on its own walls' level); a lower wing's roof + // here covers the storey below, where the rule puts it. + if ( + roofsOver(nodes, level.levelId).some((roof) => roof.parentId === level.levelId) && + (content.walls > 0 || content.zones > 0 || content.stairs > 0) + ) report( 'roof_mixed_in_storey', `${levelName} mixes roof geometry with occupied-level content; place roofs on a dedicated roof level for solo/exploded level views`, 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/corner-window.ts b/packages/core/src/agent-tools/corner-window.ts index 70a4ff49af..e1647a359d 100644 --- a/packages/core/src/agent-tools/corner-window.ts +++ b/packages/core/src/agent-tools/corner-window.ts @@ -1,22 +1,20 @@ import { z } from 'zod' import { WINDOW_STYLES } from '../building/opening-style-presets' import { WindowType } from '../schema/nodes/opening-types' +import { levelTarget } from './levels' import { measurement } from './measurement' export const addCornerWindowTool = { name: 'add_corner_window', title: 'Add corner window', description: - "A window that wraps the corner where two walls meet: one window on each wall, both running to the corner and joined, the glass fused at the corner (post none, the default) or meeting at a thin post (post post). Any angle the walls make, a bay's 135° as well as a square or an acute corner. The corner is the point the two walls end at ([x, z], as get_walls gives their ends); width is how far the glass runs along each wall from the corner, the same on both unless widths names a wall. Each side is placed by add_window's rules (height, sillHeight, windowType, style the same on both sides; with no sillHeight, centred on the lower of the two walls). Refused with a code: no_corner where no two walls end at the point, corner_ambiguous where more than two do (name two with wallIds), corner_angle for a corner nearly folded back or nearly straight, width_exceeds_wall. Returns windowIds and wallIds; the editor's window panel shows the pair under Corner.", + "A window that wraps the corner where two walls meet: one window on each wall, both running to the corner and joined, the glass fused at the corner (post none, a fixed window's default) or meeting at a thin post (post post). Any angle the walls make, a bay's 135° as well as a square or an acute corner. The corner is the point the two walls end at ([x, z], as get_walls gives their ends); width is how far the glass runs along each wall from the corner, the same on both unless widths names a wall. Each side is placed by add_window's rules (height, sillHeight, windowType, style the same on both sides; with no sillHeight, centred on the lower of the two walls). The walls are the level's (levelId, default the floor the person is viewing): stacked storeys share their corners' points. Refused with a code: no_corner where no two walls of the level end at the point, corner_ambiguous where more than two do (name two with wallIds), corner_angle for a corner nearly folded back or nearly straight, corner_not_fused for post none on a window type other than fixed, width_exceeds_wall. Returns windowIds and wallIds; the editor's window panel shows the pair under Corner.", input: { corner: z .array(z.number()) .length(2) .describe("The point where the two walls meet, [x, z] in the level's plan (m)."), - levelId: z - .string() - .optional() - .describe('The level of the walls (default: any level with walls ending there).'), + ...levelTarget, wallIds: z .array(z.string()) .length(2) @@ -27,7 +25,10 @@ export const addCornerWindowTool = { description: 'How far the glass runs along each wall from the corner (default 1.2 m).', }).optional(), widths: z - .record(z.string(), z.number().positive()) + .record( + z.string(), + measurement('length', 'm', { positive: true, description: 'The side on that wall.' }), + ) .optional() .describe('A width for a wall by its id, over width (e.g. { "wall_a": 1.6 }).'), height: measurement('length', 'm', { @@ -43,7 +44,7 @@ export const addCornerWindowTool = { .enum(['none', 'post']) .optional() .describe( - 'none: the glass fused at the corner (default); post: the glass meets at a thin post.', + "none: the glass fused at the corner (a fixed window's default); post: the glass meets at a thin post (the default, and the only choice, for the other window types, whose frames stay).", ), windowType: WindowType.optional().describe( "How it opens (default fixed), the window panel's Type row; the fused corner is a fixed window's.", diff --git a/packages/core/src/agent-tools/fences.ts b/packages/core/src/agent-tools/fences.ts new file mode 100644 index 0000000000..21edfb02cc --- /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: a kickboard on the ground), floating (the panel held off the ground on its posts) or raised (a bottom rail held above the ground, the posts to the ground: a deck guard).', + ), + materialPreset: z + .string() + .optional() + .describe('A library material (library:), found with search_materials.'), + ...levelTarget, + }, +} diff --git a/packages/core/src/agent-tools/index.ts b/packages/core/src/agent-tools/index.ts index 745c0e1b54..50bf354342 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' @@ -52,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' @@ -82,6 +85,7 @@ export const AGENT_TOOL_CONTRACTS = [ verifySceneTool, setCheckpointTool, addWallTool, + addFenceTool, addCornerWindowTool, mergeWindowsTool, addLevelTool, 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..dfa2b62597 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 walls' outline, else 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 above the top storey (the empty level directly above when it is the only one; 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 or closed walls 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, @@ -57,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..f2b3383953 100644 --- a/packages/core/src/building/corner-window.test.ts +++ b/packages/core/src/building/corner-window.test.ts @@ -1,24 +1,26 @@ import { describe, expect, test } from 'bun:test' import { cornerWindowIssues } from '../agent-operations/corner-window' import { isAgentRefusal } from '../agent-tools/refusal' +import { cornerPair } from '../lib/corner-pair' import { type AnyNode, LevelNode, WallNode, WindowNode } from '../schema' import { - cornerPair, + type CornerWindowInput, cornerPartnerUpdates, cornerSideWidth, - planCornerWindow, + planCornerWindow as planOnLevel, + strayCornerClears, unwrapCorner, wrapCorner, } 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 @@ -38,6 +40,9 @@ function cornerScene(degrees: number, extra: AnyNode[] = []): Nodes { return Object.fromEntries([level, a, b, ...extra].map((node) => [node.id, node])) } +const planCornerWindow = (nodes: Nodes, input: Omit) => + planOnLevel(nodes, { ...input, levelId: LEVEL }) + const apply = (nodes: Nodes, windows: WindowNode[]) => ({ ...nodes, ...Object.fromEntries(windows.map((window) => [window.id, window])), @@ -165,7 +170,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, @@ -217,14 +222,29 @@ describe('by hand: wrap, keep in step, unwrap', () => { }) }) - test('a window that is already one side of a pair is not adopted', () => { + // Dragged away, a side kept naming its old partner: it was never offered again, and the old + // partner kept a link to a window gone elsewhere. + test('a window waiting there whose pair is broken is adopted, and its old partner let go', () => { const near = onWall('window_near', 'wall_a', 3.3) - const taken = WindowNode.parse({ - ...onWall('window_taken', 'wall_b', 0.7, 1), - corner: { end: 'start', partnerId: 'window_elsewhere', post: 'none' }, + const stale = WindowNode.parse({ + ...onWall('window_stale', 'wall_b', 0.7, 1), + corner: { end: 'start', partnerId: 'window_old', post: 'none' }, }) - const { create } = wrapCorner(apply(cornerScene(90), [near, taken]), near.id) - expect(create?.parentId).toBe('wall_b') + const old = WindowNode.parse({ + ...onWall('window_old', 'wall_b', 3, 1), + corner: { end: 'start', partnerId: 'window_stale', post: 'none' }, + }) + const { create, updates } = wrapCorner(apply(cornerScene(90), [near, stale, old]), near.id) + expect(create).toBeNull() + expect(updates.window_stale?.corner?.partnerId).toBe('window_near') + expect(updates.window_old).toEqual({ corner: undefined }) + }) + + test('a window at a corner nearly straight has none to wrap', () => { + const near = onWall('window_near', 'wall_a', 3.4) + expect(refusalOf(() => wrapCorner(apply(cornerScene(176), [near]), near.id))).toBe( + 'corner_angle', + ) }) test('a height or sill changed on one side changes the other', () => { @@ -237,6 +257,17 @@ describe('by hand: wrap, keep in step, unwrap', () => { expect(cornerPartnerUpdates(nodes, onA.id, { width: 2 })).toBeNull() }) + test('unwrapping a pair that no longer holds still clears both links', () => { + const { windows } = planCornerWindow(cornerScene(90), { corner: [0, 0], width: 1.2 }) + const [a, b] = windows as [WindowNode, WindowNode] + const moved = { ...a, position: [2, a.position[1], a.position[2]] as [number, number, number] } + const nodes = apply(cornerScene(90), [moved, b]) + expect(Object.keys(unwrapCorner(nodes, a.id)).sort()).toEqual([a.id, b.id].sort()) + // After a drag: off its corner, both sides let go; still at it, nothing changes. + expect(Object.keys(strayCornerClears(nodes, a.id)).sort()).toEqual([a.id, b.id].sort()) + expect(strayCornerClears(apply(cornerScene(90), windows), a.id)).toEqual({}) + }) + test('unwrapping keeps both windows, plain, ending at the corner', () => { const { windows } = planCornerWindow(cornerScene(90), { corner: [0, 0], width: 1.2 }) const nodes = apply(cornerScene(90), windows) @@ -247,12 +278,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', () => { @@ -275,6 +308,27 @@ describe('one side wider or narrower', () => { }) }) + // Widened to its whole wall, a side reached both ends and was read as running to its start: the + // pair fell apart under the slider. + test('widened to its whole wall, it is still one side of the pair', () => { + const { windows } = planCornerWindow(cornerScene(90), { corner: [0, 0], width: 1.2 }) + const nodes = apply(cornerScene(90), windows) + const onA = windows.find((w) => w.parentId === 'wall_a')! + const wide = { ...onA, ...cornerSideWidth(nodes, onA.id, 4) } as WindowNode + expect(cornerPair({ ...nodes, [wide.id]: wide }, wide)?.partner.parentId).toBe('wall_b') + }) + + // Moving a wall away left both windows "paired", their corner cut open and their jambs gone. + test('its walls moved apart, the pair no longer holds', () => { + const { windows } = planCornerWindow(cornerScene(90), { corner: [0, 0], width: 1.2 }) + const nodes = apply(cornerScene(90), windows) + const apart = { + ...nodes, + wall_b: WallNode.parse({ ...nodes.wall_b, start: [0, 0.5], end: [0, 4.5] }), + } + expect(cornerPair(apart, windows[0]!)).toBeNull() + }) + test('dragged off its corner, a window is no longer one side of the pair', () => { const { windows } = planCornerWindow(cornerScene(90), { corner: [0, 0], width: 1.2 }) const [a, b] = windows as [WindowNode, WindowNode] diff --git a/packages/core/src/building/corner-window.ts b/packages/core/src/building/corner-window.ts index 3b92e95165..e21ea7cc12 100644 --- a/packages/core/src/building/corner-window.ts +++ b/packages/core/src/building/corner-window.ts @@ -2,8 +2,10 @@ import { refuse } from '../agent-tools/refusal' import { CORNER_REACH_M, cornerAngle, + cornerLinkClears, cornerPair, - DEGENERATE_CORNER_DEG, + fusesAtCorner, + isWrappableAngle, wallEndPoint, wallLengthOf, wallsEndingAt, @@ -19,14 +21,13 @@ import { type WallOpeningInput, } from './wall-openings' -export { cornerPair, windowCornerEnd } from '../lib/corner-pair' - type Nodes = Readonly> type Point = [number, number] export type CornerWindowInput = { corner: Point - levelId?: string + /** The level whose walls meet at the corner. */ + levelId: string /** The two walls, when more than two meet at the corner. */ wallIds?: [string, string] /** How far the glass runs along each wall from the corner (m), unless `widths` names the wall. */ @@ -40,9 +41,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 of `levelId` 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 @@ -52,8 +53,8 @@ export function planCornerWindow(nodes: Nodes, input: CornerWindowInput) { if (meeting.length < 2) refuse( 'no_corner', - `No two walls end at (${point.join(', ')}): a corner window goes where two walls meet; get_walls gives their ends.`, - { corner: point }, + `No two walls of level ${input.levelId}${input.wallIds ? ` among ${input.wallIds.join(', ')}` : ''} end at (${point.join(', ')}): a corner window goes where two walls of one level meet; get_walls gives their ends.`, + { corner: point, levelId: input.levelId }, ) if (meeting.length > 2) refuse( @@ -63,7 +64,7 @@ export function planCornerWindow(nodes: Nodes, input: CornerWindowInput) { ) const [a, b] = meeting as [(typeof meeting)[0], (typeof meeting)[0]] const angle = cornerAngle(a, b) - if (angle < DEGENERATE_CORNER_DEG || angle > 180 - DEGENERATE_CORNER_DEG) + if (!isWrappableAngle(angle)) refuse( 'corner_angle', `The walls at (${point.join(', ')}) meet at ${Math.round(angle)}°: too close to ${angle < 90 ? 'folded back' : 'straight'} for a corner window. Along a straight run, one add_window is the window.`, @@ -100,8 +101,15 @@ export function planCornerWindow(nodes: Nodes, input: CornerWindowInput) { }).node as WindowNode, } }) - const post = input.post ?? 'none' const [first, second] = windows as [(typeof windows)[0], (typeof windows)[0]] + const fuses = fusesAtCorner(first.node) + if (input.post === 'none' && !fuses) + refuse( + 'corner_not_fused', + `A ${first.node.windowType} window keeps its frame at the corner, so its glass cannot fuse there: only a fixed window's does. Pass post "post", or windowType "fixed".`, + { windowType: first.node.windowType }, + ) + const post = input.post ?? (fuses ? 'none' : 'post') return { windows: [ { ...first.node, corner: { end: first.end, partnerId: second.node.id, post } }, @@ -166,6 +174,8 @@ export function wrapCorner( if (others.length !== 1) refuse('no_corner', 'A corner window needs exactly one other wall ending at this corner.') const { wall: other, end: otherEnd } = others[0]! + if (!isWrappableAngle(cornerAngle({ wall, end }, { wall: other, end: otherEnd }))) + refuse('corner_angle', 'The walls meet at too sharp or too flat an angle for a corner window.') // A window already waiting at the other wall's corner is adopted, the nearest first. const waiting = Object.values(nodes) @@ -174,7 +184,7 @@ export function wrapCorner( node.type === 'window' && node.parentId === other.id && node.id !== window.id && - !node.corner && + !cornerPair(nodes, node as WindowNode) && windowNearCornerEnd(nodes, node as WindowNode) === otherEnd, ) .sort((a, b) => windowEndGap(other, a, otherEnd) - windowEndGap(other, b, otherEnd))[0] @@ -198,7 +208,7 @@ export function wrapCorner( sillHeight, windowType: window.windowType, }).node as WindowNode - const post = 'none' as const + const post = fusesAtCorner(window) ? 'none' : 'post' const slidTo = end === 'start' ? window.width / 2 : wallLengthOf(wall) - window.width / 2 return { create: { @@ -208,6 +218,8 @@ export function wrapCorner( corner: { end: otherEnd, partnerId: window.id, post }, } as WindowNode, updates: { + // The partner of a pair this window has left (it was dragged here) is plain again. + ...unwrapCorner(nodes, window.id), [window.id]: { corner: { end, partnerId: partner.id, post }, ...(Math.abs(windowEndGap(wall, window, end)) > 1e-6 @@ -218,12 +230,23 @@ export function wrapCorner( } } -/** The editor's Unwrap: both windows stay, plain, ending at the corner. */ +/** The editor's Unwrap: both windows stay, plain, where they are. */ export function unwrapCorner(nodes: Nodes, windowId: string): Record> { - const window = nodes[windowId] as WindowNode | undefined - const pair = window?.type === 'window' ? cornerPair(nodes, window) : null - if (!window || !pair) return {} - return { [window.id]: { corner: undefined }, [pair.partner.id]: { corner: undefined } } + return Object.fromEntries(cornerLinkClears(nodes, windowId).map(({ id, data }) => [id, data])) +} + +/** + * After a window moved: its `corner` cleared on both sides when it is no longer one side of its + * pair (dragged off the corner, or onto another wall); nothing while the pair holds. + */ +export function strayCornerClears( + nodes: Nodes, + windowId: string, +): Record> { + const window = nodes[windowId] + if (window?.type !== 'window' || !window.corner || cornerPair(nodes, window as WindowNode)) + return {} + return unwrapCorner(nodes, windowId) } /** diff --git a/packages/core/src/building/merge-windows.test.ts b/packages/core/src/building/merge-windows.test.ts index 6e8c8aada3..07f02cf383 100644 --- a/packages/core/src/building/merge-windows.test.ts +++ b/packages/core/src/building/merge-windows.test.ts @@ -1,11 +1,10 @@ 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 (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', @@ -41,22 +40,17 @@ 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 the merge, no refusal, for two windows that can join', () => { + expect(windowMergeOffer(scene, ['window_a', 'window_b'])).toMatchObject({ + merge: { kind: 'same-wall', windowIds: ['window_a'] }, + 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 4b8812e144..637b26e47b 100644 --- a/packages/core/src/building/merge-windows.ts +++ b/packages/core/src/building/merge-windows.ts @@ -1,9 +1,11 @@ -import { refuse } from '../agent-tools/refusal' +import { type AgentRefusal, isAgentRefusal, refuse } from '../agent-tools/refusal' import { CORNER_REACH_M, cornerAngle, + cornerLinkClears, cornerPair, - DEGENERATE_CORNER_DEG, + fusesAtCorner, + isWrappableAngle, type WallEnd, wallEndPoint, wallLengthOf, @@ -13,15 +15,15 @@ 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. * Side by side on one wall with at most `MERGE_GAP_M` between them and nothing between, they * become one window spanning both outer edges. The first window named is the one whose height, sill - * and type (corner) or type and style (one wall) the result keeps. + * and type (corner) or type and style (one wall) the result keeps. A `corner` either had that no + * longer makes a pair (its partner moved off the corner) is cleared on both sides. */ type Nodes = Readonly> @@ -105,15 +107,16 @@ export function mergeWindows(nodes: Nodes, firstId: string, secondId: string): W [first.window, second.window], [second.window, first.window], ] as const) { - if (mine.corner && mine.corner.partnerId === other.id && cornerPair(nodes, mine)) + const partnerId = cornerPair(nodes, mine)?.partner.id + if (partnerId === other.id) refuse('windows_already_joined', `${mine.id} and ${other.id} already wrap this corner.`, { windowIds: [mine.id, other.id], }) - if (mine.corner && mine.corner.partnerId !== other.id && nodes[mine.corner.partnerId]) + if (partnerId) refuse( 'window_already_joined', - `Window ${mine.id} already wraps a corner with ${mine.corner.partnerId}; unwrap it before joining it to ${other.id}.`, - { windowId: mine.id, partnerId: mine.corner.partnerId }, + `Window ${mine.id} already wraps a corner with ${partnerId}; unwrap it before joining it to ${other.id}.`, + { windowId: mine.id, partnerId }, ) } return first.wall.id === second.wall.id @@ -123,6 +126,15 @@ export function mergeWindows(nodes: Nodes, firstId: string, secondId: string): W type Side = { window: WindowNode; wall: WallNode } +/** The links the merged windows' old, broken pairs leave on windows other than these two. */ +function brokenLinkClears(nodes: Nodes, ...windows: WindowNode[]) { + const ids = new Set(windows.map((window) => window.id)) + return windows + .flatMap((window) => cornerLinkClears(nodes, window.id)) + .filter(({ id }) => !ids.has(id)) + .map(({ id, data }) => ({ id: id as AnyNodeId, data: data as Partial })) +} + function mergeOnOneWall(nodes: Nodes, first: Side, second: Side): WindowMerge { const wall = first.wall const [a, b] = [first.window, second.window] @@ -135,8 +147,9 @@ function mergeOnOneWall(nodes: Nodes, first: Side, second: Side): WindowMerge { { windowIds: [a.id, b.id], gap, limit: MERGE_GAP_M }, ) - const outerLeft = leftOf.position[0] - leftOf.width / 2 - const outerRight = rightOf.position[0] + rightOf.width / 2 + // The union of the two: a window inside the other, or wider than it, does not shrink it. + const outerLeft = Math.min(a.position[0] - a.width / 2, b.position[0] - b.width / 2) + const outerRight = Math.max(a.position[0] + a.width / 2, b.position[0] + b.width / 2) const bottom = Math.min(sillOf(a), sillOf(b)) const top = Math.max(headOf(a), headOf(b)) const centre: [number, number] = [(outerLeft + outerRight) / 2, (bottom + top) / 2] @@ -169,8 +182,14 @@ function mergeOnOneWall(nodes: Nodes, first: Side, second: Side): WindowMerge { update: [ { id: a.id as AnyNodeId, - data: { width, height: top - bottom, position: [centre[0], centre[1], a.position[2]] }, + data: { + width, + height: top - bottom, + position: [centre[0], centre[1], a.position[2]], + ...(a.corner ? { corner: undefined } : {}), + }, }, + ...brokenLinkClears(nodes, a, b), ], delete: [b.id as AnyNodeId], }, @@ -191,7 +210,7 @@ function mergeAtCorner(nodes: Nodes, first: Side, second: Side): WindowMerge { { wall: first.wall, end: joint.endA }, { wall: second.wall, end: joint.endB }, ) - if (angle < DEGENERATE_CORNER_DEG || angle > 180 - DEGENERATE_CORNER_DEG) + if (!isWrappableAngle(angle)) refuse( 'corner_angle', `The walls meet at ${Math.round(angle)}°: too close to ${angle < 90 ? 'folded back' : 'straight'} for a corner window.`, @@ -221,6 +240,7 @@ function mergeAtCorner(nodes: Nodes, first: Side, second: Side): WindowMerge { ) // Each side slides to the corner, keeping its width, if no other opening is in the way. + const post = fusesAtCorner(a) ? 'none' : 'post' const centreAt = (window: WindowNode, wall: WallNode, end: WallEnd) => end === 'start' ? window.width / 2 : wallLengthOf(wall) - window.width / 2 const placed = sides.map(({ window, wall, end }, index) => { @@ -233,11 +253,7 @@ function mergeAtCorner(nodes: Nodes, first: Side, second: Side): WindowMerge { `Sliding ${window.id} to the corner of wall ${wall.id} would overlap ${blocking.type} ${blocking.id}.`, { windowId: window.id, blockingId: blocking.id }, ) - const corner = { - end, - partnerId: sides[1 - index]!.window.id, - post: 'none' as const, - } + const corner = { end, partnerId: sides[1 - index]!.window.id, post } as const const aligned: Partial = index === 0 ? {} @@ -256,25 +272,26 @@ function mergeAtCorner(nodes: Nodes, first: Side, second: Side): WindowMerge { sillHeight: sillOf(a), height: a.height, angle: round(angle), - changes: { update: placed, delete: [] }, - message: `Joined ${a.id} and ${b.id} as one corner window across ${first.wall.id} and ${second.wall.id} (${Math.round(angle)}°), the glass fused at the corner. Both take ${a.id}'s height and sill (${metres(a.height)} tall from ${metres(sillOf(a))}) and type, as it was named first.`, + changes: { update: [...placed, ...brokenLinkClears(nodes, a, b)], delete: [] }, + message: `Joined ${a.id} and ${b.id} as one corner window across ${first.wall.id} and ${second.wall.id} (${Math.round(angle)}°), ${post === 'none' ? 'the glass fused at the corner' : `the ${a.windowType} frames meeting at the corner`}. Both take ${a.id}'s height and sill (${metres(a.height)} tall from ${metres(sillOf(a))}) and type, as it was named first.`, } } /** * 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 the merge they would make, or 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 { +): { merge: WindowMerge; refusal: null } | { merge: null; refusal: AgentRefusal } | 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 { merge: mergeWindows(nodes, selectedIds[0]!, selectedIds[1]!), refusal: null } } catch (error) { - return { reason: error instanceof Error ? error.message : 'These windows cannot be merged.' } + if (isAgentRefusal(error)) return { merge: null, refusal: error } + throw error } } 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..20154e23f6 100644 --- a/packages/core/src/commands/structure/create-zone.ts +++ b/packages/core/src/commands/structure/create-zone.ts @@ -5,7 +5,7 @@ import { segmentsIntersect } from '../../lib/polygon-relations' import { extractRooms } from '../../lib/room-graph' import { isAllocatedRoomName } from '../../lib/room-name' import { adoptableFace, zoneFaceFits } from '../../lib/room-zone-adoption' -import { type AnyNodeId, SeparatorNode, type WallNode, ZoneNode } from '../../schema' +import { type AnyNodeId, SeparatorNode, type WallNode, type ZoneNode } from '../../schema' import { getWallCurveFrameAt } from '../../systems/wall/wall-curve' import { planWallInsertion, uncoveredWallSegments } from '../../systems/wall/wall-topology' import { setZoneIntent, type ZoneIntentPatch } from './set-zone-intent' @@ -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. */ @@ -102,7 +102,7 @@ export function walledRoomAt( * The walled room a drawn polygon names instead of stacking a zone on: one that still has the name * the reconciler gave it, or any walled room when `adoptNamed` says its person's name may change. */ -export function walledRoomToName( +function walledRoomToName( nodes: StructureNodes, levelId: string, polygon: Point[], diff --git a/packages/core/src/commands/structure/index.ts b/packages/core/src/commands/structure/index.ts index e3bb8ff4de..63456e9c21 100644 --- a/packages/core/src/commands/structure/index.ts +++ b/packages/core/src/commands/structure/index.ts @@ -11,7 +11,6 @@ export { createZone, outdoorRoomConflicts, walledRoomAt, - walledRoomToName, } from './create-zone' export { type DeleteZonePayload, deleteZone, SHARED_WALLS_DELETE_MESSAGE } from './delete-zone' export { diff --git a/packages/core/src/contracts/reference-inventory.ts b/packages/core/src/contracts/reference-inventory.ts index b8161c5efa..73aa11d14b 100644 --- a/packages/core/src/contracts/reference-inventory.ts +++ b/packages/core/src/contracts/reference-inventory.ts @@ -198,8 +198,8 @@ export const EXISTING_REFERENCES: readonly ExistingReference[] = [ path: 'corner.partnerId', ...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.", + remaps: [...CLONES, 'scene-clipboard'], + note: "A corner window's other side: copied with it, the copy names the partner's copy; copied alone, or once its partner is deleted, the link is cleared.", }), row({ kind: 'window', @@ -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/index.ts b/packages/core/src/index.ts index 99779a8062..b4fa3d3758 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -140,20 +140,11 @@ export { computeCeilingSurfaceCells, parseCeilingRegionRole, } from './lib/ceiling-surface' +export { cornerPair, fusesAtCorner } from './lib/corner-pair' export { - CORNER_REACH_M, - cornerPair, - windowCornerEnd, - windowNearCornerEnd, -} from './lib/corner-pair' -export { - CORNER_SNAP_ENTER_M, - CORNER_SNAP_EXIT_M, type CornerBlock, type CornerBlockReason, type CornerSnap, - cornerBlock, - cornerSnap, evaluateCorner, } from './lib/corner-snap' export { criticallyDamped } from './lib/critically-damped' @@ -342,6 +333,7 @@ export { roundFloorElevation, } from './lib/room-floor-feasibility' export type { OpenWallEnd } from './lib/room-graph' +export { isAllocatedRoomName } from './lib/room-name' export { type BoundaryNode, type BoundarySpan, @@ -380,7 +372,7 @@ export { slotLabelFromId, slotPaintMaterial, } from './lib/slots' -export { createSnapEaser, prefersReducedMotion, SNAP_EASE_MS } from './lib/snap-ease' +export { createSnapEaser, prefersReducedMotion } from './lib/snap-ease' export { createRoomTopologyIndex, detectSpacesForLevel, @@ -601,13 +593,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/corner-pair.ts b/packages/core/src/lib/corner-pair.ts index 29b89367ed..dbd6289685 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> @@ -20,6 +20,22 @@ export const CORNER_REACH_M = 0.3 /** A corner this close to folded back or to straight is no corner (degrees). */ export const DEGENERATE_CORNER_DEG = 10 +/** Whether two walls meeting at `angle` (degrees) make a corner a window can wrap. */ +export const isWrappableAngle = (angle: number) => + angle >= DEGENERATE_CORNER_DEG && angle <= 180 - DEGENERATE_CORNER_DEG + +/** + * Whether the glass of a window fuses at a corner: a fixed, rectangular, framed window's does. The + * other families keep their frames, so their sides meet at the frames as at a post. + */ +export const fusesAtCorner = ( + window: Pick, +) => + !window.source && + window.openingKind !== 'opening' && + (window.windowType ?? 'fixed') === 'fixed' && + (window.openingShape ?? 'rectangle') === 'rectangle' + const distance = (a: readonly number[], b: readonly number[]) => Math.hypot(a[0]! - b[0]!, a[1]! - b[1]!) export const wallLengthOf = (wall: WallNode) => distance(wall.start, wall.end) @@ -62,16 +78,19 @@ export function cornerAngle( return (Math.acos(cos) * 180) / Math.PI } -/** The end of its wall a window's edge reaches, if it reaches one. */ -export function windowCornerEnd(nodes: Nodes, window: WindowNode): WallEnd | null { +/** Whether a window's edge reaches `end` of its wall. */ +export function windowReachesEnd(nodes: Nodes, window: WindowNode, end: WallEnd): boolean { const wall = nodes[window.parentId ?? ''] - if (wall?.type !== 'wall') return null - const length = wallLengthOf(wall as WallNode) - const [left, right] = [ - window.position[0] - window.width / 2, - window.position[0] + window.width / 2, - ] - return Math.abs(left) <= REACHES ? 'start' : Math.abs(right - length) <= REACHES ? 'end' : null + return wall?.type === 'wall' && Math.abs(windowEndGap(wall as WallNode, window, end)) <= REACHES +} + +/** The end of its wall a window's edge reaches, if it reaches one; `start` when it spans the wall. */ +export function windowCornerEnd(nodes: Nodes, window: WindowNode): WallEnd | null { + return windowReachesEnd(nodes, window, 'start') + ? 'start' + : windowReachesEnd(nodes, window, 'end') + ? 'end' + : null } /** How far a window's edge is from an end of its wall (m); negative past it. */ @@ -98,8 +117,9 @@ export function windowNearCornerEnd( /** * A window's corner pair: its wall, the end it runs to, the partner on the other wall and the - * angle between the walls; null when it has none, or its partner does not name it back (a deleted - * or unwrapped partner leaves no pair). + * angle between the walls; null when it has none. A `corner` that no longer makes one is no pair: + * the partner gone or not naming it back, either side moved off its corner, or the walls no longer + * meeting there on one level at an angle a window wraps. */ export function cornerPair(nodes: Nodes, window: WindowNode) { const corner = window.corner @@ -107,18 +127,27 @@ export function cornerPair(nodes: Nodes, window: WindowNode) { if (!corner || partner?.type !== 'window') return null const other = partner as WindowNode if (other.corner?.partnerId !== window.id) return null - // Moved off its corner (dragged along its wall), a window is no longer one side of the pair. if ( - windowCornerEnd(nodes, window) !== corner.end || - windowCornerEnd(nodes, other) !== other.corner.end + !windowReachesEnd(nodes, window, corner.end) || + !windowReachesEnd(nodes, other, other.corner.end) ) return null const [wall, partnerWall] = [nodes[window.parentId ?? ''], nodes[other.parentId ?? '']] - if (wall?.type !== 'wall' || partnerWall?.type !== 'wall') return null + if (wall?.type !== 'wall' || partnerWall?.type !== 'wall' || wall.id === partnerWall.id) + return null + if ( + wall.parentId !== partnerWall.parentId || + distance( + wallEndPoint(wall as WallNode, corner.end), + wallEndPoint(partnerWall as WallNode, other.corner.end), + ) > AT + ) + return null const angle = cornerAngle( { wall: wall as WallNode, end: corner.end }, { wall: partnerWall as WallNode, end: other.corner.end }, ) + if (!isWrappableAngle(angle)) return null return { wall: wall as WallNode, end: corner.end, @@ -128,3 +157,24 @@ export function cornerPair(nodes: Nodes, window: WindowNode) { post: corner.post, } } + +/** + * The updates that clear a window's `corner`, and its partner's when the partner names it back: + * Unwrap, and what a move, a delete or a merge leaves when the window is no longer one side of + * that pair. Whether the pair still holds or not, so a broken link is never left behind. + */ +export function cornerLinkClears( + nodes: Nodes, + windowId: string, +): { id: string; data: Pick }[] { + const window = nodes[windowId] + const corner = window?.type === 'window' ? (window as WindowNode).corner : undefined + if (!corner) return [] + const partner = nodes[corner.partnerId] + return [ + { id: windowId, data: { corner: undefined } }, + ...(partner?.type === 'window' && (partner as WindowNode).corner?.partnerId === windowId + ? [{ id: partner.id, data: { corner: undefined } }] + : []), + ] +} diff --git a/packages/core/src/lib/corner-snap.test.ts b/packages/core/src/lib/corner-snap.test.ts index 14fc012c69..0f857fdab1 100644 --- a/packages/core/src/lib/corner-snap.test.ts +++ b/packages/core/src/lib/corner-snap.test.ts @@ -1,15 +1,20 @@ 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' +import { type AnyNode, DoorNode, LevelNode, WallNode, WindowNode } from '../schema' +import { CORNER_SNAP_EXIT_M, type CornerInput, evaluateCorner } 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' +const cornerSnap = (nodes: Record, input: CornerInput) => + evaluateCorner(nodes, input).snap +const cornerBlock = (nodes: Record, input: CornerInput) => + evaluateCorner(nodes, input).block + /** Wall A (4 m) runs into the corner at the origin; wall B (3 m) leaves it at `degrees`. */ function scene(degrees: number, extra: AnyNode[] = [], extraWalls: AnyNode[] = []) { const rad = (degrees * Math.PI) / 180 @@ -128,13 +133,25 @@ describe('the window waiting at the other wall’s corner', () => { }) test('is not one that is already a side of a corner window', () => { + // The corner already holds a pair: wall_b's side, joined to the one on wall_a. const taken = onWall('window_taken', 'wall_b', 0.6, 1.2, { - corner: { end: 'start', partnerId: 'window_elsewhere', post: 'none' }, + corner: { end: 'start', partnerId: 'window_partner', post: 'none' }, + }) + const partner = onWall('window_partner', 'wall_a', 4 - 0.6, 1.2, { + corner: { end: 'end', partnerId: 'window_taken', post: 'none' }, }) // Taken, it is not waiting; with it in the way the match has no room, so no corner is offered. - expect(cornerSnap(scene(90, [taken]), input)).toBeNull() - expect(cornerBlock(scene(90, [taken]), input)?.reason).toBe('no_room') + expect(cornerSnap(scene(90, [taken, partner]), input)).toBeNull() + expect(cornerBlock(scene(90, [taken, partner]), input)?.reason).toBe('no_room') + }) + + test('is one whose pair is broken: its partner was deleted', () => { + const orphan = onWall('window_orphan', 'wall_b', 0.6, 1.2, { + corner: { end: 'start', partnerId: 'window_gone', post: 'none' }, + }) + + expect(cornerSnap(scene(90, [orphan]), input)?.waitingId).toBe('window_orphan') }) test('is never a draft still being placed', () => { @@ -152,10 +169,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 } @@ -211,13 +228,11 @@ describe('why a wall end is not a corner', () => { }) test('no room for the match on the other wall: an opening already stands at its corner end', () => { - const taken = onWall('window_taken', 'wall_b', 0.6, 1.2, { - corner: { end: 'start', partnerId: 'window_elsewhere', post: 'none' }, - }) + const door = onWall('window_beside', 'wall_b', 0.6 + 1.25, 1.2) - expect(cornerBlock(scene(90, [taken]), input)?.reason).toBe('no_room') - // And the ghost does not snap to a corner it cannot join. - expect(cornerSnap(scene(90, [taken]), input)).toBeNull() + expect( + cornerBlock(scene(90, [door]), { ...input, width: 2.5, centreX: 4 - 0.1 - 1.25 })?.reason, + ).toBe('no_room') }) test('a window waiting there needs no room: it is adopted', () => { @@ -227,6 +242,43 @@ describe('why a wall end is not a corner', () => { expect(cornerSnap(scene(90, [waiting]), input)?.waitingId).toBe('window_waiting') }) + // The HUD read "Join as a corner window" where the join was then refused on the click, and a plain + // window was placed without a word: the join is checked before the click. + test('a window waiting there that cannot slide to the corner: something in the way', () => { + const self = onWall('window_self', 'wall_a', centreAt(0.1)) + const waiting = onWall('window_waiting', 'wall_b', 0.6 + 0.25) + const door = DoorNode.parse({ + id: 'door_corner', + parentId: 'wall_b', + wallId: 'wall_b', + position: [0.12, 1, 0], + width: 0.2, + height: 2, + }) + const nodes = scene(90, [self, waiting, door]) + + expect(evaluateCorner(nodes, { ...input, ignoreId: 'window_self' })).toEqual({ + snap: null, + block: { reason: 'in_the_way', end: 'end' }, + }) + }) + + test('a window waiting there too tall for this wall', () => { + const self = onWall('window_self', 'wall_a', centreAt(0.1)) + const waiting = onWall('window_waiting', 'wall_b', 0.6, 1.2, { + position: [0.6, 2.2, 0], + height: 1.6, + }) + const nodes = { + ...scene(90, [self, waiting]), + wall_a: { ...scene(90, [self]).wall_a!, height: 2.4 } as AnyNode, + } + + expect(evaluateCorner(nodes, { ...input, ignoreId: 'window_self' }).block?.reason).toBe( + 'too_tall', + ) + }) + test('keeps its reason across the zone’s edge like the snap does', () => { const gap = (0.3 + CORNER_SNAP_EXIT_M) / 2 const edge = { wallId: 'wall_a', centreX: gap + 0.6, width: 1.2 } diff --git a/packages/core/src/lib/corner-snap.ts b/packages/core/src/lib/corner-snap.ts index 7fdc76d41e..d3b658f632 100644 --- a/packages/core/src/lib/corner-snap.ts +++ b/packages/core/src/lib/corner-snap.ts @@ -1,10 +1,12 @@ import { isAgentRefusal } from '../agent-tools/refusal' +import { mergeWindows } from '../building/merge-windows' import { planWallOpening } from '../building/wall-openings' import type { AnyNode, WallNode, WindowNode } from '../schema' import { CORNER_REACH_M, cornerAngle, - DEGENERATE_CORNER_DEG, + cornerPair, + isWrappableAngle, type WallEnd, wallEndPoint, wallLengthOf, @@ -14,9 +16,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 +41,10 @@ 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. With + * a window waiting, why the two cannot join: an opening in the way of sliding them to the corner, + * or the waiting window too tall for this wall. */ export type CornerBlockReason = | 'free_end' @@ -50,6 +53,8 @@ export type CornerBlockReason = | 'curved' | 'curtain' | 'no_room' + | 'in_the_way' + | 'too_tall' export type CornerBlock = { reason: CornerBlockReason; end: WallEnd } @@ -106,7 +111,7 @@ function evaluate(nodes: Nodes, input: CornerInput): Evaluation { const { wall: otherWall, end: otherEnd } = others[0]! if (isCurtain(wall as WallNode) || isCurtain(otherWall)) return blocked('curtain') const angle = cornerAngle({ wall: wall as WallNode, end }, { wall: otherWall, end: otherEnd }) - if (angle < DEGENERATE_CORNER_DEG || angle > 180 - DEGENERATE_CORNER_DEG) return blocked('angle') + if (!isWrappableAngle(angle)) return blocked('angle') const waiting = Object.values(nodes) .filter( @@ -114,12 +119,40 @@ function evaluate(nodes: Nodes, input: CornerInput): Evaluation { node.type === 'window' && node.parentId === otherWall.id && node.id !== input.ignoreId && - !node.corner && + // Free, or the partner of the window being dragged: dropped back, the pair holds. + [undefined, input.ignoreId].includes(cornerPair(nodes, node as WindowNode)?.partner.id) && !(node.metadata as { isTransient?: boolean } | null)?.isTransient && windowNearCornerEnd(nodes, node as WindowNode) === otherEnd, ) .sort((a, b) => windowEndGap(otherWall, a, otherEnd) - windowEndGap(otherWall, b, otherEnd))[0] + const centreX = end === 'start' ? input.width / 2 : length - input.width / 2 + // With a window waiting, the click merges the two: where that merge would be refused, the corner + // says why rather than placing a plain window on the click. + const self = input.ignoreId ? nodes[input.ignoreId] : undefined + if (waiting && self?.type === 'window') { + const height = input.height ?? self.height + const sill = input.sillHeight ?? self.position[1] - self.height / 2 + const placed = { + ...self, + parentId: wall.id, + width: input.width, + height, + position: [centreX, sill + height / 2, self.position[2]], + corner: undefined, + } as WindowNode + try { + mergeWindows({ ...nodes, [placed.id]: placed }, waiting.id, placed.id) + } catch (error) { + if (!isAgentRefusal(error)) throw error + return error.code === 'height_exceeds_wall' + ? blocked('too_tall') + : error.code === 'opening_overlap' + ? blocked('in_the_way') + : null + } + } + // With none waiting the click makes the match on the other wall: if that wall cannot take it, // the corner is not offered. if (!waiting) { @@ -144,7 +177,7 @@ function evaluate(nodes: Nodes, input: CornerInput): Evaluation { return { snap: { end, - centreX: end === 'start' ? input.width / 2 : length - input.width / 2, + centreX, corner: [point[0]!, point[1]!], otherWall, otherEnd, @@ -164,14 +197,3 @@ export function evaluateCorner( block: found && 'block' in found ? found.block : null, } } - -export function cornerSnap(nodes: Nodes, input: CornerInput): CornerSnap | null { - const found = evaluate(nodes, input) - return found && 'snap' in found ? found.snap : null -} - -/** Why a window within the corner zone gets no corner, or null where it does, or is out of the zone. */ -export function cornerBlock(nodes: Nodes, input: CornerInput): CornerBlock | null { - const found = evaluate(nodes, input) - return found && 'block' in found ? found.block : null -} 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/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/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/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/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/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..bb007b730e --- /dev/null +++ b/packages/core/src/services/roof-level.test.ts @@ -0,0 +1,205 @@ +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 (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' }) + }) + + 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 support level holding a roof stays a support: the roof level goes above it', () => { + const nodes = house( + { id: 'level_g', content: 'walls' }, + { id: 'level_s', content: 'roof', metadata: { role: 'support' } }, + ) + expect(planRoofLevel(nodes, 'level_g', 2)).toMatchObject({ + kind: 'roof', + create: { level: 2 }, + update: [], + }) + }) + + // Two rules disagreed: list_levels and verify_scene called a walled Roof level the roof level, + // and this service a storey, so a '+' stacked a floor above it while the agents refused its walls. + test('a declared roof level someone walled stays the roof level, kept on top', () => { + const nodes = house( + { id: 'level_g', content: 'walls' }, + { id: 'level_r', content: 'walls', metadata: { role: 'roof' } }, + ) + expect(planRoofLevel(nodes, 'level_g', 2)).toEqual({ + kind: 'roof', + levelId: 'level_r', + create: null, + update: [], + }) + expect(storeyInsertIndex(nodes, 'building_h')).toEqual({ + floorIndex: 1, + shift: [{ id: 'level_r', data: { level: 2 } }], + }) + }) +}) + +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([]) + }) + + // A porch roof drawn by hand stands on the upper storey, by the rule, at its floor: it covered + // the upper storey too, so create_roof refused the house's own roof as roof_exists. + test("a lower wing's roof on the storey above covers the storey below, not that one", () => { + const nodes = house({ id: 'level_g', content: 'walls' }, { id: 'level_1', content: 'walls' }) + const porch = RoofNode.parse({ id: 'roof_porch', parentId: 'level_1', position: [8, 0, 0] }) + const legacy = RoofNode.parse({ id: 'roof_old', parentId: 'level_1', position: [3, 2.5, 2] }) + const level = nodes.level_1 as LevelNode + nodes.level_1 = { ...level, children: [...level.children, porch.id, legacy.id] } + nodes[porch.id] = porch + nodes[legacy.id] = legacy + + expect(roofsOver(nodes, 'level_g').map((roof) => roof.id)).toEqual(['roof_porch']) + // On its own walls' level, at their tops: written before roof levels, it covers that storey. + expect(roofsOver(nodes, 'level_1').map((roof) => roof.id)).toEqual(['roof_old']) + }) +}) + +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 whose only level holds a roof (a carport) gets its new storey on top', () => { + expect(storeyInsertIndex(house({ id: 'level_c', content: 'roof' }), 'building_h')).toEqual({ + floorIndex: 1, + 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..0fad2a845d --- /dev/null +++ b/packages/core/src/services/roof-level.ts @@ -0,0 +1,196 @@ +import { getDefaultLevelName, getLevelDisplayName } from '../lib/level-name' +import { type AnyNode, LevelNode, type RoofNode } from '../schema' +import { getLevelElevations } 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' +} + +/** A level declared to carry structure, not rooms (`metadata.role: 'support'`). */ +const isSupportLevel = (level: LevelNode) => level.metadata?.role === 'support' + +const childrenOf = (nodes: Nodes, level: LevelNode) => + level.children.map((id) => nodes[id]).filter((child): child is AnyNode => child !== undefined) + +/** + * A level that is its building's roof level: declared, whatever it holds (`verify_scene` reports a + * declared one someone walled), or, unless declared a support, holding roofs and nothing a storey + * holds (a roof level written before levels declared it). The one rule every reader of a level's role goes by (`list_levels`, + * `verify_scene`, the agents' refusals, the editor's level stack). + */ +export function servesAsRoofLevel(nodes: Nodes, level: LevelNode): boolean { + if (isRoofLevel(level)) return true + // A declared support level stays one, roofs on it or not. + if (isSupportLevel(level)) return false + const children = childrenOf(nodes, level) + return ( + children.some((child) => child.type === 'roof') && + !children.some((child) => STOREY_CONTENT.has(child.type)) + ) +} + +const elevationsOf = (nodes: Nodes) => + getLevelElevations(nodes as Parameters[0]) + +/** The other levels of a level's building, in stack order, below it or above it. */ +function stackOf(nodes: Nodes, levelId: string, side: 'below' | 'above'): LevelNode[] { + const elevations = elevationsOf(nodes) + const own = elevations.get(levelId) + if (!own) return [] + return [...elevations] + .filter( + ([, entry]) => + entry.buildingId === own.buildingId && + (side === 'above' ? entry.ordinal > own.ordinal : entry.ordinal < own.ordinal), + ) + .sort(([, a], [, b]) => (side === 'above' ? a.ordinal - b.ordinal : b.ordinal - a.ordinal)) + .map(([id]) => nodes[id]) + .filter((node): node is LevelNode => node?.type === 'level') +} + +export type RoofLevelPlan = + /** A storey stands 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 }[] + } + +/** A level reused as the roof level, declared when it was not. */ +function roofLevelPlan(level: LevelNode): RoofLevelPlan { + return { + kind: 'roof', + levelId: level.id, + create: null, + update: isRoofLevel(level) + ? [] + : [ + { + id: level.id, + data: { + metadata: { ...level.metadata, role: 'roof' }, + // A name a person gave stays; an unnamed level would read as a floor. + name: + getLevelDisplayName(level) === getDefaultLevelName(level.level) + ? 'Roof' + : level.name, + }, + }, + ], + } +} + +/** + * Where a roof carried by `storeyId`'s walls lives. With a storey built above (anything on it), + * the storey directly above takes the roof over part of the one below: a porch, a lower wing. + * Else the building's roof level, above every level still to be built and every support level: + * the roof level there; the empty level directly above when it is the only one, declared; else a + * new "Roof" level `roofHeight` tall (the roof's rise over its eaves) on top. 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 = stackOf(nodes, storeyId, 'above') + const isPlain = (level: LevelNode) => !servesAsRoofLevel(nodes, level) && !isSupportLevel(level) + // The roof stands on the storey directly above, an empty one still to be built included. + if (above.some((level) => isPlain(level) && level.children.length > 0)) + return { kind: 'storey', levelId: above.find(isPlain)!.id } + const top = above.filter((level) => !servesAsRoofLevel(nodes, level)).at(-1) + const roofLevel = above.find( + (level) => servesAsRoofLevel(nodes, level) && (!top || level.level > top.level), + ) + if (roofLevel) return roofLevelPlan(roofLevel) + if (above.length === 1 && isPlain(above[0]!)) return roofLevelPlan(above[0]!) + const level = LevelNode.parse({ + name: 'Roof', + parentId: storey.parentId, + level: (top ?? storey).level + 1, + height: Math.max(roofHeight, 0.2), + children: [], + metadata: { role: 'roof' }, + }) + return { kind: 'roof', levelId: level.id, create: level, update: [] } +} + +/** + * The storey a roof covers. On a roof level, the highest storey below it that holds something + * (levels still to be built and support levels between are passed over), else the level directly + * below. On a storey, the storey below when the roof stands near its floor (a porch's or a lower + * wing's roof, seated on the walls under it, as the rule places it); else the storey itself (a roof + * written on its own walls' level, before roof levels). Null off a level. + */ +export function coveredStoreyId(nodes: Nodes, roof: RoofNode): string | null { + const level = roof.parentId ? nodes[roof.parentId] : undefined + if (level?.type !== 'level') return null + const below = stackOf(nodes, level.id, 'below') + if (!below.length) return level.id + if (servesAsRoofLevel(nodes, level)) + return ( + below.find( + (other) => + !servesAsRoofLevel(nodes, other) && !isSupportLevel(other) && other.children.length > 0, + ) ?? below[0]! + ).id + const height = elevationsOf(nodes).get(level.id)?.height ?? 0 + return roof.position[1] < height / 2 ? below[0]!.id : level.id +} + +/** The roofs over a storey, wherever they stand (`coveredStoreyId`). */ +export function roofsOver(nodes: Nodes, storeyId: string): RoofNode[] { + return Object.values(nodes).filter( + (node): node is RoofNode => node.type === 'roof' && coveredStoreyId(nodes, node) === storeyId, + ) +} + +/** + * 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) + // The lowest level is always a storey: a carport's roof on its posts is its ground floor. + let top = levels.length + while (top > 1 && 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/core/src/store/use-corner-snap-hint.test.ts b/packages/core/src/store/use-corner-snap-hint.test.ts deleted file mode 100644 index 34c8bf472f..0000000000 --- a/packages/core/src/store/use-corner-snap-hint.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -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. -describe('cornerHintRows', () => { - test('says nothing away from a wall end', () => { - 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('gives each reason its own sentence where the end is no corner', () => { - const reasons = Object.keys(CORNER_BLOCK_NOTES) as (keyof typeof CORNER_BLOCK_NOTES)[] - const labels = reasons.map((reason) => cornerHintRows(reason)[0]?.label) - - 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) - }) -}) diff --git a/packages/core/src/systems/roof/roof-elevation.test.ts b/packages/core/src/systems/roof/roof-elevation.test.ts index 827076622e..c277f242aa 100644 --- a/packages/core/src/systems/roof/roof-elevation.test.ts +++ b/packages/core/src/systems/roof/roof-elevation.test.ts @@ -220,17 +220,44 @@ describe('resolveRoofElevation', () => { // A wall-less storey slipped between roof and walls: the walls are now two // floors down and the roof freezes where it is. expect(resolveRoofElevation(roof, { ...nodes, [middle.id]: middle })).toBe(0) - // Walls on the roof's own level count (top floor without a storey above). + // Walls on the roof's own level count when the roof stands on them (a roof written on its + // walls' level); near that level's floor it is a lower wing's, on the walls below. const onParent = room(upper, [20]) const next = { ...nodes, [upper.id]: { ...upper, children: [...upper.children, ...onParent.map((wall) => wall.id)] }, ...Object.fromEntries(onParent.map((wall) => [wall.id, wall])), } - expect(resolveRoofElevation(roof, next)).toBe(20) + expect(resolveRoofElevation({ ...roof, position: [2, 20, 1] }, next)).toBe(20) + expect(resolveRoofElevation(roof, next)).toBe(1) expect(resolveRoofElevation({ ...roof, parentId: level.id }, nodes)).toBe(4) }) + // A porch roof drawn by hand goes on the storey above its walls; the upper storey's wall along + // the porch's edge lifted it to that wall's top. + test("a lower wing's roof on the storey above stands on the wing's walls, not that storey's", () => { + const { level, upper, nodes } = scene([3]) + const wing = room(level, [3], 4) + const upperWalls = room(upper, [2.5]) + const segment = RoofSegmentNode.parse({ roofType: 'shed', width: 4, depth: 3 }) + const porch = RoofNode.parse({ + parentId: upper.id, + support: { kind: 'walls' }, + position: [6, 0, 1.5], + children: [segment.id], + }) + const next: Record = { + ...nodes, + [level.id]: { ...level, children: [...level.children, ...wing.map((wall) => wall.id)] }, + [upper.id]: { ...upper, children: [...upperWalls.map((wall) => wall.id), porch.id] }, + ...Object.fromEntries( + [...wing, ...upperWalls, porch].map((node) => [node.id, node as AnyNode]), + ), + [segment.id]: { ...segment, parentId: porch.id }, + } + expect(resolveRoofElevation(porch, next)).toBe(0) + }) + test('matches a conical arc by centre and radius after replacement, without an enclosure', () => { const { level, roof, nodes } = scene([4]) const segment = RoofSegmentNode.parse({ diff --git a/packages/core/src/systems/roof/roof-elevation.ts b/packages/core/src/systems/roof/roof-elevation.ts index 1e75ab60f7..a171d6a1b1 100644 --- a/packages/core/src/systems/roof/roof-elevation.ts +++ b/packages/core/src/systems/roof/roof-elevation.ts @@ -4,6 +4,7 @@ import { } from '../../hooks/spatial-grid/spatial-grid-manager' import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync' import type { AnyNode, LevelNode, RoofNode, RoofSegmentNode, WallNode } from '../../schema' +import { coveredStoreyId, servesAsRoofLevel } from '../../services/roof-level' import { findLevelBelowId, getLevelElevations } from '../../services/storey' import { wallOverlapsSlabFootprint } from '../slab/slab-support' import { getWallArcData } from '../wall/wall-curve' @@ -36,7 +37,14 @@ export function resolveRoofElevation( // A roof usually sits on the storey above its walls, but the top floor (or a // roof armed from the walls' own level) keeps roof and walls on one level. const belowId = findLevelBelowId(levelId, elevations) - const candidateWallIds = [levelId, belowId] + // A lower wing's roof on the storey above stands on the walls below only: that storey's own + // walls along the wing's edge would lift it to their tops. + const level = nodes[levelId] as LevelNode + const supportLevelIds = + belowId && !servesAsRoofLevel(nodes, level) && coveredStoreyId(nodes, roof) === belowId + ? [belowId] + : [levelId, belowId] + const candidateWallIds = supportLevelIds .map((id) => (id ? nodes[id] : undefined)) .filter((node): node is LevelNode => node?.type === 'level') .flatMap((level) => level.children) @@ -97,7 +105,7 @@ export function resolveRoofElevation( footprints.some((polygon) => wallOverlapsSlabFootprint(wall, polygon)) ) }) - : ([levelId, belowId] + : (supportLevelIds .map((id) => id ? resolveRoomRoofFootprintOnLevel(id as LevelNode['id'], nodes, [ diff --git a/packages/core/src/utils/clone-scene-graph.test.ts b/packages/core/src/utils/clone-scene-graph.test.ts index cee17c66cd..cd16b2a12c 100644 --- a/packages/core/src/utils/clone-scene-graph.test.ts +++ b/packages/core/src/utils/clone-scene-graph.test.ts @@ -411,3 +411,29 @@ describe('unit member remap', () => { ).toBe(true) }) }) + +// A level duplicated kept its corner windows naming the originals: the copies rendered as two plain +// windows and could not be joined while the originals stood. +describe('corner window partner remap', () => { + test('a level clone joins the copied sides to each other', () => { + const level = makeNode('level_1', 'level', { children: ['wall_a', 'wall_b'] }) + const wallA = makeNode('wall_a', 'wall', { parentId: 'level_1', children: ['window_a'] }) + const wallB = makeNode('wall_b', 'wall', { parentId: 'level_1', children: ['window_b'] }) + const windowA = makeNode('window_a', 'window', { + parentId: 'wall_a', + corner: { end: 'end', partnerId: 'window_b', post: 'none' }, + }) + const windowB = makeNode('window_b', 'window', { + parentId: 'wall_b', + corner: { end: 'start', partnerId: 'window_a', post: 'none' }, + }) + const nodes = Object.fromEntries( + [level, wallA, wallB, windowA, windowB].map((node) => [node.id, node]), + ) as Record + + const { clonedNodes, idMap } = cloneLevelSubtree(nodes, 'level_1' as AnyNodeId) + const copyOf = (id: string) => clonedNodes.find((node) => node.id === idMap.get(id)) + expect(copyOf('window_a')).toMatchObject({ corner: { partnerId: idMap.get('window_b') } }) + expect(copyOf('window_b')).toMatchObject({ corner: { partnerId: idMap.get('window_a') } }) + }) +}) diff --git a/packages/core/src/utils/clone-scene-graph.ts b/packages/core/src/utils/clone-scene-graph.ts index d67c39418d..0b3ace7afa 100644 --- a/packages/core/src/utils/clone-scene-graph.ts +++ b/packages/core/src/utils/clone-scene-graph.ts @@ -435,6 +435,13 @@ export function cloneLevelSubtree( } remapFloorOpeningReferences(cloned, idMap) + // A corner window's sides are copied together, so the copies join each other; a side copied + // alone is a plain window. + if (cloned.type === 'window' && cloned.corner) { + const partnerId = idMap.get(cloned.corner.partnerId) + cloned.corner = partnerId ? { ...cloned.corner, partnerId } : undefined + } + // A copied plate or ceiling stands for the copied rooms, so the guard // accepts it as derived and the reconciler adopts it on the new level. if (cloned.type === 'slab' && cloned.zoneIds) 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/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..783b7d0765 100644 --- a/packages/editor/src/components/ui/floating-level-selector.tsx +++ b/packages/editor/src/components/ui/floating-level-selector.tsx @@ -51,7 +51,8 @@ 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' 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 @@ -533,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(() => { @@ -601,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 @@ -638,7 +631,7 @@ export function FloatingLevelSelector() { <>
- {/* Floating + at top edge */} + {/* Floating + at top edge: a floor on top, under the roof level when one tops the stack */} {!draggingLevelId && (
) : (