diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 81af7059d8..9188a9309e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -101,7 +101,7 @@ jobs: - name: Bump versions and sync inter-package references run: | BUMP=${{ inputs.bump }} - PACKAGES="core viewer editor nodes mcp ifc-converter cli" + PACKAGES="core geometry-script viewer editor nodes mcp ifc-converter cli" bump_version() { local v=$1 @@ -189,7 +189,7 @@ jobs: done echo "=== @pascal-app/* refs after sync ===" - for pkg in core viewer editor nodes mcp ifc-converter cli; do + for pkg in core geometry-script viewer editor nodes mcp ifc-converter cli; do echo "--- packages/$pkg/package.json ---" jq '{ dependencies: (.dependencies // {} | with_entries(select(.key | startswith("@pascal-app/")))), peerDependencies: (.peerDependencies // {} | with_entries(select(.key | startswith("@pascal-app/")))), devDependencies: (.devDependencies // {} | with_entries(select(.key | startswith("@pascal-app/")))) }' packages/$pkg/package.json done @@ -222,6 +222,21 @@ jobs: echo "📦 Published @pascal-app/core@$CORE_VERSION" fi + - name: Build & publish geometry-script + working-directory: packages/geometry-script + run: | + # The editor depends on it: publish before the editor. + bun run build + if [ "${{ inputs.dry-run }}" = "true" ]; then + echo "🏜️ Dry run — would publish @pascal-app/geometry-script@$GEOMETRY_SCRIPT_VERSION" + npm publish --dry-run --access public --tag "$NPM_TAG" + elif npm view "@pascal-app/geometry-script@$GEOMETRY_SCRIPT_VERSION" version >/dev/null 2>&1; then + echo "📦 @pascal-app/geometry-script@$GEOMETRY_SCRIPT_VERSION is already published; continuing release recovery" + else + npm publish --access public --tag "$NPM_TAG" + echo "📦 Published @pascal-app/geometry-script@$GEOMETRY_SCRIPT_VERSION" + fi + - name: Build & publish viewer working-directory: packages/viewer run: | @@ -320,7 +335,7 @@ jobs: git add -A PKGS="" TAGS="" - for pkg in core viewer editor nodes mcp ifc-converter cli; do + for pkg in core geometry-script viewer editor nodes mcp ifc-converter cli; do PKGS="$PKGS @pascal-app/$pkg@$RELEASE_VERSION" TAGS="$TAGS @pascal-app/$pkg@$RELEASE_VERSION" done diff --git a/bun.lock b/bun.lock index 6ee743934a..1dbc3e57fa 100644 --- a/bun.lock +++ b/bun.lock @@ -157,6 +157,8 @@ "@dnd-kit/utilities": "^3.2.2", "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", + "@pascal-app/geometry-script": "^1.0.3", + "@pascal-app/ifc-converter": "^1.0.3", "@pascal-app/lingo": "^0.2.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-context-menu": "^2.2.16", @@ -232,12 +234,28 @@ "typescript-eslint": "^8.50.0", }, }, + "packages/geometry-script": { + "name": "@pascal-app/geometry-script", + "version": "1.0.3", + "dependencies": { + "@pascal-app/core": "^1.0.3", + "three-bvh-csg": "^0.0.18", + }, + "devDependencies": { + "@pascal/typescript-config": "*", + "@types/bun": "^1.3.0", + "@types/three": "^0.184.0", + "typescript": "6.0.3", + }, + "peerDependencies": { + "three": "^0.186", + }, + }, "packages/ifc-converter": { "name": "@pascal-app/ifc-converter", "version": "1.0.3", "dependencies": { "@pascal-app/core": "^1.0.3", - "nanoid": "^5.1.6", "web-ifc": "^0.0.77", }, "devDependencies": { @@ -768,6 +786,8 @@ "@pascal-app/editor": ["@pascal-app/editor@workspace:packages/editor"], + "@pascal-app/geometry-script": ["@pascal-app/geometry-script@workspace:packages/geometry-script"], + "@pascal-app/ifc-converter": ["@pascal-app/ifc-converter@workspace:packages/ifc-converter"], "@pascal-app/lingo": ["@pascal-app/lingo@0.2.0", "", { "peerDependencies": { "react": "^19" }, "optionalPeers": ["react"] }, "sha512-Ws+Utm23SmDbMB0Jc7joIp4KPkXhs4Z1boUcW9dl8nC3ATJz+a50F7/MyVTbE3QaL5Fr+YwNfsiOMxrKdhwPhA=="], diff --git a/packages/core/src/agent-operations/add-object.ts b/packages/core/src/agent-operations/add-object.ts new file mode 100644 index 0000000000..f5b31e9351 --- /dev/null +++ b/packages/core/src/agent-operations/add-object.ts @@ -0,0 +1,276 @@ +import { refuse } from '../agent-tools/refusal' +import { artifactUrl } from '../lib/artifact-store' +import { + isScriptedNode, + type ScriptedNode, + scriptedSize, + scriptInteractive, + scriptSource, +} from '../lib/geometry-script-node' +import { geometryRestingHeight, resettledPosition } from '../lib/geometry-surfaces' +import { + type AnyNode, + type CompiledGeometryScript, + type GeometryScriptMount, + type GeometryScriptParamValue, + generateId, + ItemNode, +} from '../schema' +import { targetLevel } from './level-target' +import type { AgentOperation } from './types' + +type Vec3 = [number, number, number] + +export type AddObjectInput = { + /** Absent for a params-only edit: the host compiled the object's stored script. */ + code?: string + params?: Record + nodeId?: string + parentId?: string + position?: number[] + /** Degrees about Y, as the contract parses it. */ + rotation?: number + side?: 'front' | 'back' + name?: string + category?: string + /** What the surface's compile produced from `code` (compiled before the operation runs). */ + compiled: CompiledGeometryScript +} + +const ATTACH: Record = { + floor: undefined, + wall: 'wall', + 'wall-side': 'wall-side', + ceiling: 'ceiling', +} + +const HOSTS: Record = { + floor: ['level', 'item'], + wall: ['wall'], + 'wall-side': ['wall'], + ceiling: ['ceiling'], +} + +function scriptAsset( + compiled: CompiledGeometryScript, + input: AddObjectInput, + previous: ItemNode['asset'] | undefined, +): ItemNode['asset'] { + const { min, max } = compiled.manifest.bounds + const restingHeight = geometryRestingHeight(compiled.manifest) + return { + id: `script_${compiled.sha256.slice(0, 16)}`, + category: input.category ?? previous?.category ?? 'object', + name: input.name ?? previous?.name ?? 'Authored object', + thumbnail: previous?.thumbnail ?? '', + source: 'mine', + src: artifactUrl(compiled.sha256), + dimensions: [max[0] - min[0], max[1] - min[1], max[2] - min[2]], + attachTo: ATTACH[compiled.mount], + surface: restingHeight === null ? undefined : { height: restingHeight }, + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + interactive: scriptInteractive(compiled.manifest), + } +} + +const round = (value: number) => Math.round(value * 1000) / 1000 + +function summary(node: { id: string }, compiled: CompiledGeometryScript, orphanedSlots: string[]) { + const { bounds, parts, slots, lights, params, triangles, cutout, animations } = compiled.manifest + return { + nodeId: node.id, + mount: compiled.mount, + size: bounds.max.map((v, i) => round(v - bounds.min[i]!)), + parts: parts.map((part) => (part.type ? `${part.id} (${part.type})` : part.id)), + slots: slots.map((slot) => slot.id), + lights: lights.map((light) => light.id), + animations: animations.map((clip) => clip.name), + params: params.map((spec) => ({ ...spec, value: compiled.params[spec.id] })), + cutout, + triangles, + ...(orphanedSlots.length > 0 + ? { + orphanedSlots, + note: `Paint on ${orphanedSlots.join(', ')} is kept but no longer shows: the new output has no slot with that id.`, + } + : {}), + } +} + +/** + * `add_object`: the item a compiled three.js module becomes. Not in + * AGENT_OPERATIONS: each surface compiles `code` first (the chat in its + * worker, the MCP on the server) and passes the result as `compiled`. + * The artifact is referenced by hash and its bounds become the item's dimensions; editing + * keeps the item's identity, placement, children and paint. + */ +export const addObject: AgentOperation = (nodes, input, context) => { + const { compiled } = input + const rotation: Vec3 | undefined = + input.rotation === undefined ? undefined : [0, (input.rotation * Math.PI) / 180, 0] + + if (input.nodeId) { + const previous = authoredObject(nodes, input.nodeId) + if (previous.type !== 'item') + refuse( + 'use_opening_tool', + `${previous.id} is a ${previous.type}: rebuild it with add_${previous.type} and nodeId.`, + { id: previous.id, type: previous.type }, + ) + const slotIds = new Set(compiled.manifest.slots.map((slot) => slot.id)) + const orphanedSlots = Object.keys(previous.slots ?? {}).filter((id) => !slotIds.has(id)) + const next = ItemNode.parse({ + ...previous, + name: input.name ?? previous.name, + position: (input.position as Vec3 | undefined) ?? previous.position, + rotation: rotation ?? previous.rotation, + side: input.side ?? previous.side, + source: scriptSource(compiled), + asset: scriptAsset(compiled, input, previous.asset), + }) + // Children resting on or hanging from the object follow its new geometry. + const resettled: { id: string; position: Vec3 }[] = [] + for (const childId of previous.children) { + const child = nodes[childId] + if (child?.type !== 'item' || child.wallId) continue + const position = resettledPosition(compiled.manifest, child, next.scale) + if (!position || position.every((v, i) => Math.abs(v - child.position[i]!) < 1e-4)) continue + resettled.push({ id: child.id, position }) + } + return { + result: { + ...summary(next, compiled, orphanedSlots), + ...(resettled.length > 0 ? { resettled: resettled.map((entry) => entry.id) } : {}), + }, + changes: { + update: [ + { id: next.id, data: next }, + ...resettled.map(({ id, position }) => ({ id, data: { position } })), + ], + }, + } + } + + const parent = input.parentId ? nodes[input.parentId] : targetLevel(nodes, {}, context) + if (!parent) + refuse('node_not_found', `Node not found: ${input.parentId}.`, { id: input.parentId }) + const hosts = HOSTS[compiled.mount] + if (!hosts.includes(parent.type)) { + refuse( + 'wrong_host', + `A ${compiled.mount} object goes on a ${hosts.join(' or ')}, not on a ${parent.type}. Pass parentId of a ${hosts[0]}, or change \`mount\`.`, + { mount: compiled.mount, parentType: parent.type }, + ) + } + const asset = scriptAsset(compiled, input, undefined) + const node = ItemNode.parse({ + object: 'node', + id: generateId('item'), + type: 'item', + name: input.name ?? asset.name, + parentId: parent.id, + ...(parent.type === 'wall' ? { wallId: parent.id, side: input.side ?? 'front' } : {}), + position: (input.position as Vec3 | undefined) ?? [0, 0, 0], + rotation: rotation ?? [0, 0, 0], + source: scriptSource(compiled), + asset, + }) + return { + result: summary(node, compiled, []), + changes: { create: [{ node, parentId: parent.id }] }, + } +} + +export type RescriptOpeningInput = { + nodeId: string + /** Where it goes; without one its bottom edge stays put. */ + position?: number[] + name?: string + /** What the host compiled: new code, or the stored script with new params. */ + compiled: CompiledGeometryScript +} + +/** + * `add_window` / `add_door` with a nodeId: a window or door built from (or + * given) a script, rebuilt from what the host compiled. Its size is what the + * script built; marks, hosting and the opening's own fields are kept. + */ +export const rescriptOpening: AgentOperation = (nodes, input) => { + const { compiled } = input + const previous = nodes[input.nodeId] + if (!previous) refuse('node_not_found', `Node not found: ${input.nodeId}.`, { id: input.nodeId }) + if (previous.type !== 'window' && previous.type !== 'door') + refuse('not_an_opening', `${input.nodeId} is a ${previous.type}, not a window or door.`, { + id: input.nodeId, + type: previous.type, + }) + const slotIds = new Set(compiled.manifest.slots.map((slot) => slot.id)) + const orphanedSlots = Object.keys(previous.slots ?? {}).filter((id) => !slotIds.has(id)) + // A window or door keeps its place on the wall and its bottom edge; its size is what the script built. + if (compiled.mount !== 'wall') + refuse('wrong_mount', `A ${previous.type}'s script uses mount 'wall'.`, { + mount: compiled.mount, + }) + const [width, height] = scriptedSize(compiled.manifest) + // Given a position, that is where it goes; otherwise its bottom edge stays put. + const [x, y, z] = previous.position + const placed: Vec3 = (input.position as Vec3 | undefined) ?? [ + x, + y - previous.height / 2 + height / 2, + z, + ] + // A wider rebuild stays on its wall, as a new opening does. + const wall = previous.wallId ? nodes[previous.wallId] : undefined + const wallLength = + wall?.type === 'wall' ? Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) : 0 + const position: Vec3 = + wallLength >= width + ? [Math.min(wallLength - width / 2, Math.max(width / 2, placed[0])), placed[1], placed[2]] + : placed + return { + result: summary(previous, compiled, orphanedSlots), + changes: { + update: [ + { + id: previous.id, + data: { + name: input.name ?? previous.name, + source: scriptSource(compiled), + width, + height, + position, + }, + }, + ], + }, + } +} + +/** The scripted node `get_source` and a params-only rebuild act on, or a refusal. */ +export function authoredObject(nodes: Record, nodeId: string): ScriptedNode { + const node = nodes[nodeId] + if (!node) refuse('node_not_found', `Node not found: ${nodeId}.`, { id: nodeId }) + if (!isScriptedNode(node)) + refuse( + 'not_authored', + `${nodeId} is a ${node.type} without a script; only objects, windows and doors built from code have one.`, + { id: nodeId, type: node.type }, + ) + return node +} + +/** What `get_source` answers once the host has the module's text. */ +export function readSourceResult(node: ScriptedNode, code: string) { + return { + nodeId: node.id, + type: node.type, + name: node.name, + code, + params: node.source.manifest.params.map((spec) => ({ + ...spec, + value: node.source.params[spec.id] ?? spec.default, + })), + } +} diff --git a/packages/core/src/agent-operations/find-by-type.ts b/packages/core/src/agent-operations/find-by-type.ts new file mode 100644 index 0000000000..17d5741e2c --- /dev/null +++ b/packages/core/src/agent-operations/find-by-type.ts @@ -0,0 +1,102 @@ +import type { AnyNode, ItemNode } from '../schema' +import { levelIdOf } from './scene-queries' +import type { AgentOperation } from './types' + +type Vec3 = [number, number, number] + +const round = (v: number) => Math.round(v * 1000) / 1000 + +/** A point in an item's frame, in its level's frame (items on a level: position + yaw). */ +function toLevel(item: ItemNode, [x, y, z]: Vec3): Vec3 { + const yaw = item.rotation[1] ?? 0 + const [sx, sy, sz] = item.scale + const lx = x * sx + const lz = z * sz + return [ + round(item.position[0] + Math.cos(yaw) * lx + Math.sin(yaw) * lz), + round(item.position[1] + y * sy), + round(item.position[2] - Math.sin(yaw) * lx + Math.cos(yaw) * lz), + ] +} + +function boundsInLevel(item: ItemNode, bounds: { min: Vec3; max: Vec3 }) { + const corners: Vec3[] = [] + for (const x of [bounds.min[0], bounds.max[0]]) + for (const y of [bounds.min[1], bounds.max[1]]) + for (const z of [bounds.min[2], bounds.max[2]]) corners.push(toLevel(item, [x, y, z])) + return { + min: [0, 1, 2].map((k) => Math.min(...corners.map((c) => c[k]!))) as Vec3, + max: [0, 1, 2].map((k) => Math.max(...corners.map((c) => c[k]!))) as Vec3, + } +} + +const emitsLight = (node: AnyNode) => + node.type === 'item' && + Boolean(node.asset.interactive?.effects.some((effect) => effect.kind === 'light')) + +const matchesWord = (text: string | undefined, word: string) => + Boolean(text) && + text! + .toLowerCase() + .split(/[^a-z0-9]+/) + .includes(word) + +/** + * `find_by_type`: everything of one type, nodes and the typed parts of + * authored objects alike. Parts are read from each object's manifest; the + * object stays the thing that is selected, moved and edited. + */ +export const findByType: AgentOperation<{ type: string; levelId?: string }> = ( + nodes, + { type, levelId }, +) => { + const word = type.trim().toLowerCase().replace(/s$/, '') + const results: Record[] = [] + for (const node of Object.values(nodes)) { + const level = levelIdOf(nodes, node.id) + if (levelId && level !== levelId) continue + const item = node.type === 'item' ? node : null + const typed = (item?.source?.manifest.parts ?? []).filter((part) => part.type) + const typedParts = typed.filter((part) => part.type === word) + // An authored object whose typed parts are all of this type is one (a + // lantern); a mixed one (a porch) answers with its parts. + const wholeAuthored = typedParts.length > 0 && typedParts.length === typed.length + const wholeMatch = + node.type === word || + wholeAuthored || + (item && + (matchesWord(item.asset.category, word) || + (word === 'light' && emitsLight(node) && typedParts.length === 0))) + if (wholeMatch) { + results.push({ + id: node.id, + name: node.name ?? item?.asset.name, + nodeType: node.type, + levelId: level, + }) + continue + } + for (const part of typedParts) { + results.push({ + id: node.id, + name: node.name ?? item!.asset.name, + nodeType: node.type, + levelId: level, + part: part.id, + partType: part.type, + ...(part.bounds && item!.parentId === level + ? { bounds: boundsInLevel(item!, part.bounds) } + : {}), + }) + } + } + return { + result: { + type: word, + count: results.length, + nodes: results.filter((entry) => !entry.part).length, + parts: results.filter((entry) => entry.part).length, + results, + }, + } +} diff --git a/packages/core/src/agent-operations/index.ts b/packages/core/src/agent-operations/index.ts index 170df51ad3..7ae5f32762 100644 --- a/packages/core/src/agent-operations/index.ts +++ b/packages/core/src/agent-operations/index.ts @@ -1,14 +1,17 @@ import { deleteNode } from './delete-node' import { duplicateLevel } from './duplicate-level' +import { findByType } from './find-by-type' import { getNode } from './get-node' import { getLevelSummary, getWalls, getZones } from './level-reads' import { listLevels } from './list-levels' import { verifyScene } from './verify-scene' +export * from './add-object' export * from './apply-changes' export * from './delete-node' export * from './door-clearance' export * from './duplicate-level' +export * from './find-by-type' export * from './get-node' export * from './layout-clearance' export * from './level-reads' @@ -29,4 +32,5 @@ export const AGENT_OPERATIONS = { duplicate_level: duplicateLevel, verify_scene: verifyScene, delete_node: deleteNode, + find_by_type: findByType, } as const diff --git a/packages/core/src/agent-tools/add-object.ts b/packages/core/src/agent-tools/add-object.ts new file mode 100644 index 0000000000..a541b1e947 --- /dev/null +++ b/packages/core/src/agent-tools/add-object.ts @@ -0,0 +1,81 @@ +import { z } from 'zod' +import { measurement } from './measurement' +import { NodeId } from './node-id' + +const DESCRIPTION = `Build an object by writing a plain three.js module, the way you would in any three.js project. Use it for what the catalog and the structure tools cannot reproduce faithfully: custom columns and capitals, mouldings and trim, panels, lanterns and fixtures, exposed beams, vaulted or tray ceiling bodies, canopies, a porch, railings, built-ins. Pascal runs the module in a sandbox, stores the result and places it as one object the user can move, paint, and ask you to edit again. + +Module shape (import THREE from the three package as usual; the addons below too): + // also available: three/addons/utils/BufferGeometryUtils.js, three/addons/geometries/{RoundedBoxGeometry,ConvexGeometry,LoftGeometry,ParametricGeometry}.js, three-bvh-csg (Brush, Evaluator, SUBTRACTION, ADDITION, INTERSECTION) + export const params = { width: { default: 4.8, min: 3, max: 8, step: 0.1, unit: 'm', label: 'Width' } } + export const mount = 'floor' // 'floor' | 'wall-side' (on a wall face) | 'wall' (through a wall, like a window) | 'ceiling' + export default function build({ params, THREE }) { const group = new THREE.Group(); /* … */ return group } + +One object is one feature that changes together: a porch, a railing run, a fireplace surround, a ceiling with its beams. Never a whole house, and never walls, rooms, floors, roofs, stairs, doors or windows: those have their own tools. + +Conventions (they make the object work in Pascal; follow them): +- Metres, Y up, modelled as it stands. Pascal puts the bottom-centre of the bounds at the placement point; for wall-side the back face sits on the wall and the object faces +Z. +- Paint: name every material slot_ (slot_trim, slot_frame, slot_metal) and reuse one material per finish; a material named "glass" renders as glass. +- Parts: name the few groups a person would point at part: (part:column_left, part:canopy, part:landing), usually 2–24, a group per part, not every mesh. Set userData.type on parts someone would look for: column, beam, slab, roof, railing, panel, trim, step, light. +- Lights: add a THREE.PointLight or SpotLight named light: where the bulb is; it becomes a switchable light. +- Motion: put THREE.AnimationClips on the returned group's .animations, as in any three.js project; tracks target . or . and may use any transform property (position, rotation, rotation[y], quaternion, scale); material and visibility tracks do not animate. A clip named open becomes the object's open/close control (close plays a clip named close, or open reversed); a clip named loop runs continuously; every other clip gets its own play toggle labelled with its name (name it for the person: "Twirl", "Music"). Write as many clips as the object needs. +- Wall opening: a mesh named cutout (wall mount), shaped like the hole and as deep as the wall or deeper, is cut out of the host wall in that shape and never renders (a niche, a vent, a pass-through). +- Sockets: an empty Object3D named anchor: marks where other things attach. +- No textures, network or DOM. At most 300k triangles, 32 materials, 60 m per side. + +Edit: pass nodeId with new code and/or params (params alone rebuild the stored script; read it first with get_source to change the code); identity, placement and paint are kept. The result lists the size, parts, slots, lights, animations and params.` + +export const addObjectTool = { + name: 'add_object', + title: 'Build object', + description: DESCRIPTION, + input: { + code: z + .string() + .min(1) + .max(48_000) + .optional() + .describe( + 'The three.js module (see the tool description). Required to create; omit to rebuild an object with new params.', + ), + params: z + .record(z.string(), z.union([z.number(), z.boolean(), z.string()])) + .optional() + .describe('Values for the params the module declares; omitted ones use their defaults.'), + nodeId: NodeId.optional().describe( + 'Edit this existing authored object instead of creating one.', + ), + parentId: NodeId.optional().describe( + 'Host for a new object: a level (default: the floor in view), a wall (wall / wall-side mounts), a ceiling, or an item it rests on.', + ), + position: z + .array(z.number()) + .length(3) + .optional() + .describe( + 'Placement in the host frame, metres. Level: [x, y, z] (y = 0 on the floor). Wall: [distance along the wall from its start, height of the bottom, 0].', + ), + rotation: measurement('angle', 'deg', { + description: 'Y-axis rotation (default: 0).', + }).optional(), + side: z + .enum(['front', 'back']) + .optional() + .describe('Which wall face a wall-side object sits on.'), + name: z.string().max(120).optional().describe('What the user would call it ("Front porch").'), + category: z + .string() + .max(60) + .optional() + .describe('What it is, one word or two ("porch", "lantern", "ceiling", "trim").'), + }, +} + +export const getSourceTool = { + name: 'get_source', + title: 'Read object script', + description: + 'The three.js module an object (or a window or door built from code) runs, with its params and their current values. Read it before changing its code, then pass the edited module back with the same nodeId: to add_object for an object, to add_window / add_door for a window or door.', + input: { + nodeId: NodeId.describe('An object, window or door built from code.'), + }, +} diff --git a/packages/core/src/agent-tools/find-by-type.ts b/packages/core/src/agent-tools/find-by-type.ts new file mode 100644 index 0000000000..76655cb757 --- /dev/null +++ b/packages/core/src/agent-tools/find-by-type.ts @@ -0,0 +1,16 @@ +import { z } from 'zod' +import { NodeId } from './node-id' + +export const findByTypeTool = { + name: 'find_by_type', + title: 'Find by type', + description: + "Find everything of one type in the scene, wherever it lives: nodes of that kind (column, door, window, wall, stair…), items of that category, lights (any item that emits light), and typed parts inside objects built with add_object (a porch's columns, a vault's recessed lights). Parts are read-only: they come back with their object's id, the part id and its bounds in level coordinates; edit them through their object.", + input: { + type: z + .string() + .min(1) + .describe('What to find, one word: light, column, door, window, beam, panel, railing, trim…'), + levelId: NodeId.optional().describe('Only this level. Default: every level.'), + }, +} diff --git a/packages/core/src/agent-tools/index.ts b/packages/core/src/agent-tools/index.ts index 1ca7de673e..1d9bde8f0b 100644 --- a/packages/core/src/agent-tools/index.ts +++ b/packages/core/src/agent-tools/index.ts @@ -1,3 +1,5 @@ +import { addObjectTool, getSourceTool } from './add-object' +import { findByTypeTool } from './find-by-type' import { duplicateLevelTool, getLevelSummaryTool, @@ -9,6 +11,8 @@ import { import { deleteNodeTool, getNodeTool } from './nodes' import { addDoorTool, addWindowTool } from './wall-openings' +export * from './add-object' +export * from './find-by-type' export * from './levels' export * from './measurement' export { NodeId } from './node-id' @@ -32,4 +36,7 @@ export const AGENT_TOOL_CONTRACTS = [ duplicateLevelTool, verifySceneTool, deleteNodeTool, + addObjectTool, + getSourceTool, + findByTypeTool, ] as const diff --git a/packages/core/src/agent-tools/wall-openings.ts b/packages/core/src/agent-tools/wall-openings.ts index c4a50ca2a6..6914637799 100644 --- a/packages/core/src/agent-tools/wall-openings.ts +++ b/packages/core/src/agent-tools/wall-openings.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { DOOR_STYLES, WINDOW_STYLES } from '../building/opening-style-presets' +import { DoorType, WindowType } from '../schema/nodes/opening-types' import { measurement } from './measurement' import { NodeId } from './node-id' @@ -19,13 +20,48 @@ const placement = { ), } +const script = (kind: string) => ({ + code: z + .string() + .min(1) + .max(48_000) + .optional() + .describe( + `A three.js module for a ${kind} the fields cannot express (a fan grille, tracery, carved trim): the same module and conventions as add_object, with mount 'wall'. Its size is what it builds (name params width and height so the ${kind}'s size controls edit them), and its cutout mesh cuts the wall. Make it open like a real ${kind}: add an \`open\` clip (${kind === 'door' ? 'the leaves swinging or sliding' : 'the sash sliding or swinging'}; \`close\` is optional, \`open\` reversed by default) unless it is fixed. Fields first; code only beyond them.`, + ), + params: z + .record(z.string(), z.union([z.number(), z.boolean(), z.string()])) + .optional() + .describe('Values for the params the module declares.'), +}) + +const outline = (archDefault: string) => ({ + openingShape: z + .enum(['rectangle', 'rounded', 'arch']) + .optional() + .describe( + 'Outline of the opening: rectangle (default), rounded top corners, or arch (round top).', + ), + archHeight: measurement('length', 'm', { + positive: true, + description: `Rise of the arch above its straight sides, within height (arch only; default ${archDefault}).`, + }).optional(), + cornerRadius: measurement('length', 'm', { + positive: true, + description: 'Radius of the rounded top corners (rounded only; default 0.15 m).', + }).optional(), +}) + export const addDoorTool = { name: 'add_door', title: 'Add door', description: - 'Add a door to an existing straight wall at t (0..1 along it). The door slides to stay on the wall and reports clamped. Refused with a code, as in the editor: curved walls, walls shorter than the door, and overlapping another door, window or wall item unless force is set.', + 'Add a door to an existing straight wall at t (0..1 along it). The door slides to stay on the wall and reports clamped. Refused with a code, as in the editor: curved walls, walls shorter than the door, and overlapping another door, window or wall item unless force is set. Match the reference with the outline (rectangle, rounded, arch), doorType and style.', input: { - wallId: NodeId.describe('The wall to add the door to.'), + wallId: NodeId.optional().describe('The wall to add the door to.'), + nodeId: NodeId.optional().describe( + 'Rebuild this door instead of adding one: new code and/or params (get_source first to change its code). Its native fields change with update_node.', + ), ...placement, width: measurement('length', 'm', { positive: true, @@ -40,6 +76,11 @@ export const addDoorTool = { .enum(['inward', 'outward']) .optional() .describe('Which way the door opens (default inward).'), + ...outline('0.45 m'), + ...script('door'), + doorType: DoorType.optional().describe( + 'How it opens (default hinged); garage types for garage doors.', + ), style: z .enum(DOOR_STYLES) .optional() @@ -53,9 +94,12 @@ export const addWindowTool = { name: 'add_window', title: 'Add window', description: - "Add a window to an existing straight wall at t (0..1 along it), on sillHeight above the floor. It slides to stay on the wall and under the wall's ceiling, and reports clamped. Refused with a code, as in the editor: curved walls, walls shorter than the window, and overlapping another door, window or wall item unless force is set.", + "Add a window to an existing straight wall at t (0..1 along it), on sillHeight above the floor. It slides to stay on the wall and under the wall's ceiling, and reports clamped. Refused with a code, as in the editor: curved walls, walls shorter than the window, and overlapping another door, window or wall item unless force is set. Match the reference with the outline (rectangle, rounded, arch), windowType and panes (columns × rows, or a style).", input: { - wallId: NodeId.describe('The wall to add the window to.'), + wallId: NodeId.optional().describe('The wall to add the window to.'), + nodeId: NodeId.optional().describe( + 'Rebuild this window instead of adding one: new code and/or params (get_source first to change its code). Its native fields change with update_node.', + ), ...placement, width: measurement('length', 'm', { positive: true, @@ -69,6 +113,23 @@ export const addWindowTool = { min: 0, description: 'Height from the floor to the bottom of the window (default 0.9 m).', }).optional(), + ...outline('0.35 m'), + ...script('window'), + windowType: WindowType.optional().describe('How it opens (default fixed).'), + columns: z + .number() + .int() + .min(1) + .max(12) + .optional() + .describe("Panes across, equal widths; overrides the style's panes."), + rows: z + .number() + .int() + .min(1) + .max(12) + .optional() + .describe("Panes up, equal heights; overrides the style's panes."), style: z .enum(WINDOW_STYLES) .optional() diff --git a/packages/core/src/building/wall-openings.ts b/packages/core/src/building/wall-openings.ts index 3e98c32845..6147373eb9 100644 --- a/packages/core/src/building/wall-openings.ts +++ b/packages/core/src/building/wall-openings.ts @@ -1,8 +1,10 @@ import { refuse } from '../agent-tools/refusal' +import { scriptedSize, scriptSource } from '../lib/geometry-script-node' import { wallSupportForNodes } from '../lib/opening-floor-datum' import { type AnyNode, type AnyNodeId, + type CompiledGeometryScript, DoorNode, getScaledDimensions, type ItemNode, @@ -10,6 +12,7 @@ import { WindowNode, } from '../schema' import { getCurtainWallConfig } from '../schema/nodes/curtain-wall' +import type { DoorType, WindowType } from '../schema/nodes/opening-types' import { getWallPlaneTop } from '../services/storey' import { getWallCurveLength, isCurvedWall } from '../systems/wall/wall-curve' import { resolveWallTop } from '../systems/wall/wall-top' @@ -161,7 +164,7 @@ export function hasWallChildOverlap( export type WallOpeningInput = { kind: 'door' | 'window' - wallId: string + wallId?: string t?: number position?: number width?: number @@ -171,8 +174,19 @@ export type WallOpeningInput = { swingDirection?: 'inward' | 'outward' style?: string force?: boolean + openingShape?: 'rectangle' | 'rounded' | 'arch' + archHeight?: number + cornerRadius?: number + doorType?: DoorType + windowType?: WindowType + columns?: number + rows?: number + /** A compiled script the opening is built from; its bounds set width and height. */ + compiled?: CompiledGeometryScript } +const equalRatios = (count: number) => Array.from({ length: count }, () => 1 / count) + const DEFAULTS = { door: { width: 0.9, height: 2.1 }, window: { width: 1.5, height: 1.5, sillHeight: 0.9 }, @@ -187,6 +201,11 @@ const metres = (value: number) => `${value.toFixed(2)} m` */ export function planWallOpening(nodes: Nodes, input: WallOpeningInput) { const { kind, wallId } = input + if (!wallId) + refuse( + 'wall_required', + `Say which wall the ${kind} goes on (wallId), or pass nodeId to rebuild one.`, + ) const host = nodes[wallId] if (!host) refuse('wall_not_found', `Wall not found: ${wallId}.`, { wallId }) if (host.type !== 'wall') @@ -216,8 +235,12 @@ export function planWallOpening(nodes: Nodes, input: WallOpeningInput) { 'Say where on the wall: t (or position) from 0 at its start to 1 at its end.', ) - const width = input.width ?? DEFAULTS[kind].width - const height = input.height ?? DEFAULTS[kind].height + const { compiled } = input + if (compiled && compiled.mount !== 'wall') + refuse('wrong_mount', `A ${kind}'s script uses mount 'wall'.`, { mount: compiled.mount }) + const [scriptedWidth, scriptedHeight] = compiled ? scriptedSize(compiled.manifest) : [] + const width = scriptedWidth ?? input.width ?? DEFAULTS[kind].width + const height = scriptedHeight ?? input.height ?? DEFAULTS[kind].height const wallLength = lengthOf(wall) if (wallLength < width) refuse( @@ -264,6 +287,10 @@ export function planWallOpening(nodes: Nodes, input: WallOpeningInput) { parentId: wallId, width, height, + ...(input.openingShape ? { openingShape: input.openingShape } : {}), + ...(input.archHeight === undefined ? {} : { archHeight: Math.min(input.archHeight, height) }), + ...(input.cornerRadius === undefined ? {} : { cornerRadius: input.cornerRadius }), + ...(compiled ? { source: scriptSource(compiled) } : {}), } const node = kind === 'door' @@ -272,10 +299,14 @@ export function planWallOpening(nodes: Nodes, input: WallOpeningInput) { hingesSide: input.hingesSide ?? 'left', swingDirection: input.swingDirection ?? 'inward', ...getDoorStyleOverrides(input.style as DoorStyle | undefined), + ...(input.doorType ? { doorType: input.doorType } : {}), }) : WindowNode.parse({ ...base, ...getWindowStyleOverrides(input.style as WindowStyle | undefined), + ...(input.windowType ? { windowType: input.windowType } : {}), + ...(input.columns ? { columnRatios: equalRatios(input.columns) } : {}), + ...(input.rows ? { rowRatios: equalRatios(input.rows) } : {}), }) return { diff --git a/packages/core/src/contracts/reference-inventory.ts b/packages/core/src/contracts/reference-inventory.ts index 8defe4f872..a81e2d1463 100644 --- a/packages/core/src/contracts/reference-inventory.ts +++ b/packages/core/src/contracts/reference-inventory.ts @@ -721,6 +721,24 @@ export const NON_REFERENCES: readonly { kind: string; path: string; reason: stri reason: 'Inline versioned recipe (R7 stores recipes above 24 KiB by hash).', }, { kind: 'procedural-item', path: 'parameters.@key', reason: 'Recipe parameter name.' }, + // Scripted nodes (an authored item, or a window or door built from code) share one `source`. + ...(['item', 'window', 'door'] as const).flatMap((kind) => [ + ...( + [ + 'source.manifest.anchors[].id', + 'source.manifest.lights[].id', + 'source.manifest.params[].id', + 'source.manifest.parts[].id', + 'source.manifest.slots[].id', + ] as const + ).map((path) => ({ + kind, + path, + reason: "Defines a key in a scripted node's compiled manifest, read from its script.", + })), + { kind, path: 'source.params.@key', reason: 'Script parameter name.' }, + { kind, path: 'source.params.*', reason: 'Script parameter value.' }, + ]), { kind: 'scan', path: 'layers.@key', reason: 'Layer visibility flag name.' }, { kind: 'site', path: 'frontEdge', reason: "Index of the lot polygon's street-facing edge." }, ...[ diff --git a/packages/core/src/hooks/scene-registry/item-clip-registry.ts b/packages/core/src/hooks/scene-registry/item-clip-registry.ts index 3b8d63b0ab..5f9b85c8ad 100644 --- a/packages/core/src/hooks/scene-registry/item-clip-registry.ts +++ b/packages/core/src/hooks/scene-registry/item-clip-registry.ts @@ -1,10 +1,12 @@ import type * as THREE from 'three' export type ItemClipEntry = { - /** The catalog clip to re-emit (e.g. a fan's "On" spin). */ + /** The clip to re-emit (e.g. a fan's "On" spin). */ clip: THREE.AnimationClip /** Plays looping in the baked viewer (ambient motion) vs once. */ loop: boolean + /** Suffix of the baked clip name, `: `; the viewer drives `loop`. */ + name: string } /** @@ -14,6 +16,7 @@ export type ItemClipEntry = { * its own. The item renderer registers the resolved clip per node id while the * scene is live; `glb-export` reads this and retargets the clip onto the baked * item subtree. Door/window motion is synthesized separately and never goes - * here. Keyed by node id; cleared with the rest of the scene refs on unload. + * here. Keyed by node id (an authored object registers each of its clips); + * cleared with the rest of the scene refs on unload. */ -export const itemClipRegistry = new Map() +export const itemClipRegistry = new Map() diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 07679cf40c..8e0affd8bf 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -100,6 +100,15 @@ export { type WallConstructionResolution, } from './hooks/spatial-grid/support-host-patch' export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query' +export { + ARTIFACT_URL_PREFIX, + type ArtifactStore, + artifactHash, + artifactUrl, + configureArtifactStore, + getArtifactStore, + resolveArtifactUrl, +} from './lib/artifact-store' export { type AssemblyDiagnostic, type AssemblyDiagnosticCode, @@ -161,6 +170,22 @@ export { withFloorStepOverride, withoutFloorStepOverrideKeys, } from './lib/floor-step-finish' +export { + isScriptedNode, + type ScriptedNode, + scriptedSize, + scriptInteractive, + scriptSource, +} from './lib/geometry-script-node' +export { + flushMountRotation, + geometryRestingHeight, + geometrySurfaceAt, + geometryUndersideAt, + mountsFlush, + nearestPointIn, + resettledPosition, +} from './lib/geometry-surfaces' export { type ExposedInterval, exposedIntervals, diff --git a/packages/core/src/lib/artifact-store.ts b/packages/core/src/lib/artifact-store.ts new file mode 100644 index 0000000000..b6d84be72f --- /dev/null +++ b/packages/core/src/lib/artifact-store.ts @@ -0,0 +1,57 @@ +/** + * Content-addressed geometry artifacts. Nodes reference an artifact as + * `artifact://` and never by URL, so where the bytes live is the + * host's choice: the hosted app stores them per project behind an access + * check; the default keeps them in memory for the session. + */ + +export const ARTIFACT_URL_PREFIX = 'artifact://' + +export type ArtifactStore = { + /** A loadable URL for a stored artifact, or null when this store does not have it. */ + url: (sha256: string) => string | null + /** Stores the bytes under their hash; resolves once `url` returns a URL for it. */ + put: (sha256: string, bytes: ArrayBuffer | Uint8Array, mimeType: string) => Promise + /** A stored text artifact (an authored object's script), or null when it is missing or unreadable here. */ + text: (sha256: string) => Promise +} + +const memory = new Map() + +const memoryStore: ArtifactStore = { + url: (sha256) => memory.get(sha256)?.url ?? null, + put: async (sha256, bytes, mimeType) => { + if (memory.has(sha256)) return + memory.set(sha256, { + url: URL.createObjectURL(new Blob([bytes as BlobPart], { type: mimeType })), + bytes, + }) + }, + text: async (sha256) => { + const stored = memory.get(sha256) + return stored ? new TextDecoder().decode(stored.bytes) : null + }, +} + +let store: ArtifactStore = memoryStore + +export function configureArtifactStore(next: ArtifactStore | null): void { + store = next ?? memoryStore +} + +export function getArtifactStore(): ArtifactStore { + return store +} + +export const artifactUrl = (sha256: string) => `${ARTIFACT_URL_PREFIX}${sha256}` + +export function artifactHash(url: string): string | null { + if (!url.startsWith(ARTIFACT_URL_PREFIX)) return null + const sha = url.slice(ARTIFACT_URL_PREFIX.length) + return /^[0-9a-f]{64}$/.test(sha) ? sha : null +} + +export function resolveArtifactUrl(url: string): string | null { + const sha = artifactHash(url) + return sha ? store.url(sha) : null +} diff --git a/packages/core/src/lib/geometry-script-node.ts b/packages/core/src/lib/geometry-script-node.ts new file mode 100644 index 0000000000..ccc37818b8 --- /dev/null +++ b/packages/core/src/lib/geometry-script-node.ts @@ -0,0 +1,81 @@ +import type { CompiledGeometryScript } from '../schema' +import type { DoorNode } from '../schema/nodes/door' +import type { ItemNode } from '../schema/nodes/item' +import type { WindowNode } from '../schema/nodes/window' + +/** A node built from a three.js script: an authored item, or a window or door with a script source. */ +export type ScriptedNode = (ItemNode | WindowNode | DoorNode) & { + source: NonNullable +} + +export const isScriptedNode = ( + node: { type: string; source?: unknown } | undefined, +): node is ScriptedNode => + Boolean(node?.source) && + (node!.type === 'item' || node!.type === 'window' || node!.type === 'door') + +/** The `source` a compile produces, the same on every kind. */ +export const scriptSource = (compiled: CompiledGeometryScript) => ({ + kind: 'script' as const, + language: 'three' as const, + script: compiled.script, + params: compiled.params, + artifact: compiled.sha256, + manifest: compiled.manifest, +}) + +/** Width, height and depth of what the script built. */ +export function scriptedSize( + manifest: CompiledGeometryScript['manifest'], +): [number, number, number] { + const { min, max } = manifest.bounds + return [max[0] - min[0], max[1] - min[1], max[2] - min[2]] +} + +/** + * The item's controls from what the module emitted: a light switch for its + * lights, an open/close toggle for an `open` clip (closing plays `close`, or + * `open` reversed), a `loop` clip that runs throughout, and a play toggle per + * other clip, labelled with its name. + */ +export function scriptInteractive( + manifest: CompiledGeometryScript['manifest'], +): ItemNode['asset']['interactive'] { + const controls: NonNullable['controls'] = [] + const effects: NonNullable['effects'] = [] + if (manifest.lights.length > 0) { + controls.push({ kind: 'toggle', label: 'Lights', default: true }) + for (const light of manifest.lights) { + effects.push({ + kind: 'light', + color: light.color, + intensityRange: [0, light.intensity], + distance: light.distance, + offset: light.position, + }) + } + } + const clip = (name: string) => manifest.animations.some((animation) => animation.name === name) + if (clip('open')) { + effects.push({ + kind: 'animation', + mode: 'open-close', + control: controls.length, + clips: { on: 'open', off: clip('close') ? 'close' : undefined }, + }) + controls.push({ kind: 'toggle', label: 'Open', default: false }) + } + if (clip('loop')) effects.push({ kind: 'animation', mode: 'ambient', clips: { loop: 'loop' } }) + // Every other clip gets its own play toggle, labelled with its name. + for (const { name } of manifest.animations) { + if (name === 'open' || name === 'close' || name === 'loop') continue + effects.push({ + kind: 'animation', + mode: 'ambient', + control: controls.length, + clips: { on: name }, + }) + controls.push({ kind: 'toggle', label: name, default: false }) + } + return effects.length > 0 ? { controls, effects } : undefined +} diff --git a/packages/core/src/lib/geometry-surfaces.ts b/packages/core/src/lib/geometry-surfaces.ts new file mode 100644 index 0000000000..71eea829b8 --- /dev/null +++ b/packages/core/src/lib/geometry-surfaces.ts @@ -0,0 +1,198 @@ +import type { GeometryArtifactManifest } from '../schema/geometry-source' + +type Surface = GeometryArtifactManifest['surfaces'][number] +type Manifest = Pick + +/** Part types whose tops shelter rather than hold: nothing is placed on a porch roof. */ +const SHELTER_TYPES = new Set(['roof', 'canopy', 'awning', 'ceiling']) + +function receives(manifest: Manifest, surface: Surface): boolean { + if (!surface.part) return true + const type = manifest.parts.find((part) => part.id === surface.part)?.type + return !(type && SHELTER_TYPES.has(type)) +} + +function contains(polygon: [number, number][], x: number, z: number): boolean { + let inside = false + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const [xi, zi] = polygon[i]! + const [xj, zj] = polygon[j]! + if (zi > z !== zj > z && x < ((xj - xi) * (z - zi)) / (zj - zi) + xi) inside = !inside + } + return inside +} + +function area(polygon: [number, number][]): number { + let sum = 0 + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + sum += (polygon[j]![0] + polygon[i]![0]) * (polygon[j]![1] - polygon[i]![1]) + } + return Math.abs(sum) / 2 +} + +/** Non-roof surfaces covering at least half the object's footprint: its landing, its top. */ +function mainSurfaces(manifest: Manifest & Pick): Surface[] { + const { min, max } = manifest.bounds + const footprint = (max[0] - min[0]) * (max[2] - min[2]) + return manifest.surfaces.filter( + (surface) => receives(manifest, surface) && area(surface.polygon) >= footprint / 2, + ) +} + +const highest = (surfaces: Surface[]) => + surfaces.reduce((best, s) => (!best || s.y > best.y ? s : best), null) + +/** + * Where something dropped at local (x, z) comes to rest on an authored + * object: its main surface when the point is over it (a bench anywhere on a + * porch lands on the landing, not on the beam), otherwise the highest + * non-roof surface under the point. `maxY` caps the search. Null when + * nothing is under the point. + */ +export function geometrySurfaceAt( + manifest: Manifest & Pick, + x: number, + z: number, + maxY = Number.POSITIVE_INFINITY, +): Surface | null { + const under = (surface: Surface) => surface.y <= maxY && contains(surface.polygon, x, z) + return ( + highest(mainSurfaces(manifest).filter(under)) ?? + highest(manifest.surfaces.filter((s) => receives(manifest, s) && under(s))) + ) +} + +/** + * The one height an authored object offers as "where things rest" (the + * item's `asset.surface`): its highest main surface, so a porch rests things + * on its landing and a table on its top. Null when it has none. + */ +export function geometryRestingHeight( + manifest: Manifest & Pick, +): number | null { + return highest(mainSurfaces(manifest))?.y ?? null +} + +/** + * Where a ceiling item hangs from an authored object above local (x, z): the + * lowest underside over the point (a beam before the vault plane above it), + * with its height there — sloped undersides included. Null when none is over it. + */ +export function geometryUndersideAt( + manifest: Pick, + x: number, + z: number, +): { part?: string; y: number; normal: [number, number, number] } | null { + let best: { part?: string; y: number; normal: [number, number, number] } | null = null + for (const underside of manifest.undersides) { + const [a, b, c, d] = underside.plane + if (b === 0 || !contains(underside.polygon, x, z)) continue + const y = -(a * x + c * z + d) / b + if (!best || y < best.y) best = { part: underside.part, y, normal: [a, b, c] } + } + return best +} + +/** + * The rotation that seats a flush fixture (a recessed can) on a sloped + * underside: its +Y goes into the surface, against the downward `normal`, + * then it keeps its own turn `yaw`. Euler XYZ, as items store rotation. + */ +export function flushMountRotation( + normal: readonly [number, number, number], + yaw: number, +): [number, number, number] { + // Quaternion turning +Y onto -normal (the direction into the surface). + const [vx, vy, vz] = [-normal[0], -normal[1], -normal[2]] + let [qx, qy, qz, qw] = [vz, 0, -vx, 1 + vy] + const length = Math.hypot(qx, qy, qz, qw) || 1 + ;[qx, qy, qz, qw] = [qx / length, qy / length, qz / length, qw / length] + // Then the fixture's own yaw about its local +Y. + const [sy, cy] = [Math.sin(yaw / 2), Math.cos(yaw / 2)] + const [x, y, z, w] = [qx * cy - qz * sy, qw * sy + qy * cy, qz * cy + qx * sy, qw * cy - qy * sy] + const m11 = 1 - 2 * (y * y + z * z) + const m12 = 2 * (x * y - w * z) + const m13 = 2 * (x * z + w * y) + const m22 = 1 - 2 * (x * x + z * z) + const m23 = 2 * (y * z - w * x) + const m32 = 2 * (y * z + w * x) + const m33 = 1 - 2 * (x * x + y * y) + const ry = Math.asin(Math.max(-1, Math.min(1, m13))) + return Math.abs(m13) < 0.9999999 + ? [Math.atan2(-m23, m33), ry, Math.atan2(-m12, m11)] + : [Math.atan2(m32, m22), ry, 0] +} + +/** At or under this height a ceiling fixture mounts flush (a can, a surface light) rather than hangs. */ +const FLUSH_MOUNT_MAX_HEIGHT = 0.15 + +/** + * Whether a ceiling fixture sits flush on a surface (tilting with a slope) + * rather than hanging plumb: flagged `recessed`, or shallow enough that it + * can only be a can or a surface light (catalog data often lacks the flag). + */ +export function mountsFlush(asset: { + recessed?: boolean + dimensions?: readonly number[] +}): boolean { + return Boolean(asset.recessed) || (asset.dimensions?.[1] ?? 1) <= FLUSH_MOUNT_MAX_HEIGHT +} + +/** The closest point to (x, z) inside a convex outline (itself when inside). */ +export function nearestPointIn( + polygon: readonly [number, number][], + x: number, + z: number, +): [number, number] { + if (contains(polygon as [number, number][], x, z)) return [x, z] + let best: [number, number] = [x, z] + let bestDistance = Number.POSITIVE_INFINITY + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const [ax, az] = polygon[j]! + const [bx, bz] = polygon[i]! + const dx = bx - ax + const dz = bz - az + const t = Math.max(0, Math.min(1, ((x - ax) * dx + (z - az) * dz) / (dx * dx + dz * dz || 1))) + const px = ax + t * dx + const pz = az + t * dz + const distance = Math.hypot(px - x, pz - z) + if (distance < bestDistance) { + bestDistance = distance + best = [px, pz] + } + } + return best +} + +/** + * Where a child item of an authored object sits after the object is rebuilt: + * a resting item back on the surface under it (moved onto the main surface + * when that shrank away from it), a hanging one from the underside above it. + * Positions are in the object's frame; `scale` is the object's. Null when + * nothing applies (wall-mounted children, objects without surfaces). + */ +export function resettledPosition( + manifest: Pick, + child: { + position: readonly [number, number, number] + asset: { attachTo?: string; recessed?: boolean; dimensions?: readonly number[] } + }, + scale: readonly [number, number, number], +): [number, number, number] | null { + const [sx, sy, sz] = scale + const x = child.position[0] / sx + const z = child.position[2] / sz + if (child.asset.attachTo === 'ceiling') { + const underside = geometryUndersideAt(manifest, x, z) + if (!underside) return null + const drop = mountsFlush(child.asset) ? 0.02 : (child.asset.dimensions?.[1] ?? 0) + return [child.position[0], underside.y * sy - drop, child.position[2]] + } + if (child.asset.attachTo) return null + const surface = geometrySurfaceAt(manifest, x, z) + if (surface) return [child.position[0], surface.y * sy, child.position[2]] + const main = mainSurfaces(manifest).sort((a, b) => b.y - a.y)[0] + if (!main) return null + const [nx, nz] = nearestPointIn(main.polygon, x, z) + return [nx * sx, main.y * sy, nz * sz] +} diff --git a/packages/core/src/schema/asset-url.ts b/packages/core/src/schema/asset-url.ts index 5497bd26c6..d0e4e64576 100644 --- a/packages/core/src/schema/asset-url.ts +++ b/packages/core/src/schema/asset-url.ts @@ -10,7 +10,7 @@ import { z } from 'zod' * * This validator rejects URLs that don't match the scheme allowlist below. */ -const ALLOWED_SCHEMES = ['asset:', 'blob:', 'https:', 'data:image/'] as const +const ALLOWED_SCHEMES = ['artifact:', 'asset:', 'blob:', 'https:', 'data:image/'] as const /** * Optional environment variable that narrows which `https:` origins are @@ -35,6 +35,7 @@ function readAllowedOrigins(): readonly string[] | undefined { function isAllowedAssetUrl(url: string): boolean { if (typeof url !== 'string' || url.length === 0) return false if (url.startsWith('asset://')) return true // internal handle + if (/^artifact:\/\/[0-9a-f]{64}$/.test(url)) return true // content-addressed artifact if (url.startsWith('blob:')) return true // in-memory reference if (url.startsWith('data:image/')) return true // inline image only (never data:text/html) if (url.startsWith('/')) return true // app-relative path @@ -59,6 +60,7 @@ function isAllowedAssetUrl(url: string): boolean { /** * Zod validator for asset-style URL fields. Accepts: * - `asset://…` internal handles + * - `artifact://` content-addressed artifacts (see lib/artifact-store) * - `blob:…` in-memory references * - `data:image/…` inline images (not `data:text/html` or other types) * - `/…` app-relative paths @@ -70,7 +72,7 @@ function isAllowedAssetUrl(url: string): boolean { */ export const AssetUrl = z.string().refine(isAllowedAssetUrl, { message: - 'URL must be asset://, blob:, data:image/, /path, or https://. http://localhost allowed for dev.', + 'URL must be artifact://, asset://, blob:, data:image/, /path, or https://. http://localhost allowed for dev.', }) export type AssetUrl = z.infer diff --git a/packages/core/src/schema/geometry-source.ts b/packages/core/src/schema/geometry-source.ts new file mode 100644 index 0000000000..dc93600622 --- /dev/null +++ b/packages/core/src/schema/geometry-source.ts @@ -0,0 +1,125 @@ +import { z } from 'zod' + +/** A script source is persisted inline; this caps one node's share of a scene operation. */ +export const GEOMETRY_SCRIPT_MAX_BYTES = 48 * 1024 +/** The manifest rides inline in the node; the compiler keeps it under this. */ +export const GEOMETRY_MANIFEST_MAX_BYTES = 24 * 1024 +export const GEOMETRY_SCRIPT_MIME_TYPE = 'text/javascript' + +const finite = z.number().finite() +const vec3 = z.tuple([finite, finite, finite]) + +export const GeometryScriptParamValue = z.union([finite, z.boolean(), z.string()]) +export type GeometryScriptParamValue = z.infer + +/** A control the script declares in `export const params`. */ +export const GeometryScriptParamSpec = z.object({ + id: z.string(), + label: z.string().optional(), + kind: z.enum(['number', 'boolean', 'string']), + default: GeometryScriptParamValue, + min: finite.optional(), + max: finite.optional(), + step: finite.optional(), + unit: z.string().optional(), + options: z.array(z.string()).optional(), +}) +export type GeometryScriptParamSpec = z.infer + +/** + * What the compiler read from the script's output, by naming convention: + * `part:` objects, `slot_` materials, `anchor:` empties, lights + * (`light:` empties or three.js lights), a `cutout` mesh and a `collider` + * mesh. Positions are in the artifact's frame (bottom-centre origin). + */ +export const GeometryArtifactManifest = z.object({ + bounds: z.object({ min: vec3, max: vec3 }), + params: z.array(GeometryScriptParamSpec).default([]), + parts: z + .array( + z.object({ + id: z.string(), + label: z.string().optional(), + /** What the part is, for queries ("column", "beam", "slab"); from `userData.type`. */ + type: z.string().optional(), + bounds: z.object({ min: vec3, max: vec3 }).optional(), + }), + ) + .default([]), + /** Upward-facing flat areas things can rest on, e.g. a porch landing: height and XZ outline. */ + surfaces: z + .array( + z.object({ + part: z.string().optional(), + y: finite, + polygon: z.array(z.tuple([finite, finite])), + }), + ) + .default([]), + slots: z.array(z.object({ id: z.string(), label: z.string().optional() })).default([]), + anchors: z + .array(z.object({ id: z.string(), position: vec3, normal: vec3.optional() })) + .default([]), + lights: z + .array( + z.object({ + id: z.string(), + position: vec3, + color: z.string(), + intensity: finite, + distance: finite.optional(), + }), + ) + .default([]), + /** + * Downward-facing areas ceiling items hang from (a vault plane, a soffit, a + * beam): XZ outline and the plane a·x + b·y + c·z + d = 0, so a sloped + * underside gives its height anywhere inside the outline. + */ + undersides: z + .array( + z.object({ + part: z.string().optional(), + polygon: z.array(z.tuple([finite, finite])), + plane: z.tuple([finite, finite, finite, finite]), + }), + ) + .default([]), + /** The module's AnimationClips; `open`, `close` and `loop` drive the object's controls. */ + animations: z.array(z.object({ name: z.string(), duration: finite })).default([]), + cutout: z.boolean().default(false), + collider: z.boolean().default(false), + triangles: z.number().int().nonnegative(), +}) +export type GeometryArtifactManifest = z.infer + +export const GeometryScriptMount = z.enum(['floor', 'wall', 'wall-side', 'ceiling']) +export type GeometryScriptMount = z.infer + +/** What a compile hands the scene: the artifact's hash, how it mounts, the resolved params and the manifest. */ +export type CompiledGeometryScript = { + /** sha256 of the GLB. */ + sha256: string + /** sha256 of the module text that produced it. */ + script: string + mount: GeometryScriptMount + params: Record + manifest: GeometryArtifactManifest +} + +/** + * Geometry authored as a plain three.js module (`export const params`, + * `export default function build({ params, inputs, THREE, lib })`). The + * compiled artifact is what renders; the code only re-runs on an edit. + */ +export const GeometryScriptSource = z.object({ + kind: z.literal('script'), + language: z.literal('three').default('three'), + /** sha256 of the module's UTF-8 text, a `text/javascript` artifact: the code never rides in the scene. */ + script: z.string().regex(/^[0-9a-f]{64}$/), + params: z.record(z.string(), GeometryScriptParamValue).default({}), + /** sha256 of the GLB the current code + params compiled to. */ + artifact: z.string().regex(/^[0-9a-f]{64}$/), + manifest: GeometryArtifactManifest, +}) +export type GeometryScriptSource = z.infer diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 059154aba8..eed1df5a17 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -24,6 +24,17 @@ export { } from './compiled-node-parsers' // Cut intents (F5b) export { CutIntent, CutShape } from './cut' +export { + type CompiledGeometryScript, + GEOMETRY_MANIFEST_MAX_BYTES, + GEOMETRY_SCRIPT_MAX_BYTES, + GEOMETRY_SCRIPT_MIME_TYPE, + GeometryArtifactManifest, + GeometryScriptMount, + GeometryScriptParamSpec, + GeometryScriptParamValue, + GeometryScriptSource, +} from './geometry-source' export type { MaterialMapProperties, MaterialMaps, diff --git a/packages/core/src/schema/nodes/door.ts b/packages/core/src/schema/nodes/door.ts index 43f8dca774..f98b198c7e 100644 --- a/packages/core/src/schema/nodes/door.ts +++ b/packages/core/src/schema/nodes/door.ts @@ -1,6 +1,7 @@ import dedent from 'dedent' import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' +import { GeometryScriptSource } from '../geometry-source' import { MaterialSchema } from '../material' import { DoorType } from './opening-types' @@ -44,6 +45,11 @@ export const DoorNode = BaseNode.extend({ // door body), `glass`. Value = a `MaterialRef` (`library:` / `scene:`). // Absent = the body/glass default. Mirrors `ShelfNode.slots`. slots: z.record(z.string(), z.string()).optional(), + /** + * A three.js script the door is built from instead of its parametric frame, as on an item: + * width and height are the compiled bounds and the wall cuts the script's `cutout` mesh. + */ + source: GeometryScriptSource.optional(), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), diff --git a/packages/core/src/schema/nodes/item.ts b/packages/core/src/schema/nodes/item.ts index b04a21fad3..aa0dc1dd35 100644 --- a/packages/core/src/schema/nodes/item.ts +++ b/packages/core/src/schema/nodes/item.ts @@ -3,6 +3,7 @@ import { z } from 'zod' import { AssetUrl } from '../asset-url' import { BaseNode, nodeType, objectId } from '../base' import type { CollectionId } from '../collections' +import { GeometryScriptSource } from '../geometry-source' // --- Control descriptors --- @@ -42,6 +43,14 @@ const controlSchema = z.discriminatedUnion('kind', [ const animationEffectSchema = z.object({ kind: z.literal('animation'), + // ambient (default): the toggle starts/stops looping clips (a fan). + // open-close: \`on\` plays once and holds, closing plays \`off\` or \`on\` + // reversed, and \`loop\` runs throughout (authored objects: a door, a hatch). + mode: z.enum(['ambient', 'open-close']).optional(), + // The toggle (index into controls) driving this effect; absent = the first + // toggle, as catalog items have always worked. Authored objects set it so + // each of their clips has its own control. + control: z.number().int().nonnegative().optional(), clips: z.object({ on: z.string().optional(), off: z.string().optional(), @@ -170,6 +179,10 @@ export const ItemNode = BaseNode.extend({ // registry default. A dangling ref renders the default (never blocks). slots: z.record(z.string(), z.string()).optional(), + // Geometry authored as a script. When present, `asset.src` is its compiled + // artifact and `asset.dimensions` its bounds; absent = a catalog GLB. + source: GeometryScriptSource.optional(), + asset: assetSchema, }).describe(dedent`Item node - used to represent a item in the building - position: position in level coordinate system (or parent coordinate system if attached) @@ -183,6 +196,7 @@ export const ItemNode = BaseNode.extend({ - rotation: corrective rotation for the model - scale: corrective scale for the model - tags: tags associated with the item + - source: optional three.js script the asset was compiled from (script hash, params, artifact hash, manifest) `) export type ItemNode = z.infer diff --git a/packages/core/src/schema/nodes/window.ts b/packages/core/src/schema/nodes/window.ts index a868157550..57adbc749a 100644 --- a/packages/core/src/schema/nodes/window.ts +++ b/packages/core/src/schema/nodes/window.ts @@ -1,6 +1,7 @@ import dedent from 'dedent' import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' +import { GeometryScriptSource } from '../geometry-source' import { MaterialSchema } from '../material' import { WindowType } from './opening-types' @@ -24,6 +25,11 @@ export const WindowNode = BaseNode.extend({ // `glass`. Value = a `MaterialRef` (`library:` / `scene:`). Absent = // the frame/glass default. Mirrors `ShelfNode.slots`. slots: z.record(z.string(), z.string()).optional(), + /** + * A three.js script the window is built from instead of its parametric frame, as on an item: + * width and height are the compiled bounds and the wall cuts the script's `cutout` mesh. + */ + source: GeometryScriptSource.optional(), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), diff --git a/packages/editor/package.json b/packages/editor/package.json index 14c7926847..e4d2e3ea83 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -29,6 +29,7 @@ "@dnd-kit/utilities": "^3.2.2", "@iconify/react": "^6.0.2", "@number-flow/react": "^0.6.0", + "@pascal-app/geometry-script": "^1.0.3", "@pascal-app/ifc-converter": "^1.0.3", "@pascal-app/lingo": "^0.2.0", "@radix-ui/react-alert-dialog": "^1.1.15", diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index d6e5ebd668..80d6a833b3 100644 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -144,6 +144,14 @@ const MENU_Y_OFFSETS: Record = { export function getMenuYOffset(node: AnyNode | null): number { if (!node) return MENU_Y_OFFSET_DEFAULT + EXTRA_MENU_LIFT + // A window or door built from a script has a height arrow only when its script declares height. + if ( + (node.type === 'door' || node.type === 'window') && + node.source && + !node.source.manifest.params.some((spec) => spec.id === 'height') + ) { + return MENU_Y_OFFSET_DEFAULT + EXTRA_MENU_LIFT + } if (node.type === 'stair-segment') { return (MENU_Y_OFFSETS[`stair-${node.segmentType}`] ?? MENU_Y_OFFSET_DEFAULT) + EXTRA_MENU_LIFT } @@ -236,6 +244,28 @@ function getObjectGeometryKey(object: THREE.Object3D): string { return parts.join('|') } +const _meshBox = new THREE.Box3() + +/** The bounds of what renders: hidden objects and invisible-material hit boxes are skipped. */ +function setFromVisibleMeshes(box: THREE.Box3, root: THREE.Object3D): void { + box.makeEmpty() + root.updateWorldMatrix(true, true) + const visit = (object: THREE.Object3D) => { + if (!object.visible) return + const mesh = object as THREE.Mesh + const material = mesh.material as THREE.Material | THREE.Material[] | undefined + const shown = Array.isArray(material) ? material.some((m) => m.visible) : material?.visible + if (mesh.isMesh && shown && mesh.geometry) { + mesh.geometry.computeBoundingBox() + if (mesh.geometry.boundingBox) { + box.union(_meshBox.copy(mesh.geometry.boundingBox).applyMatrix4(mesh.matrixWorld)) + } + } + for (const child of object.children) visit(child) + } + visit(root) +} + function setNodeDerivedMenuAnchor( node: AnyNode, object: THREE.Object3D, @@ -450,7 +480,12 @@ export function FloatingActionMenu() { if (needsRecompute) { const effectiveNode = getEffectiveNode(node) if (!setNodeDerivedMenuAnchor(effectiveNode, obj, anchorRef.current)) { - _anchorBox.setFromObject(obj) + // Built from a script: its hidden hit box, cutout and collider are not what the person sees. + if ('source' in effectiveNode && effectiveNode.source) + setFromVisibleMeshes(_anchorBox, obj) + if (_anchorBox.isEmpty() || !('source' in effectiveNode && effectiveNode.source)) { + _anchorBox.setFromObject(obj) + } if (!_anchorBox.isEmpty()) { _anchorBox.getCenter(_anchorCenter) // Position above the object. Per-type offsets clear each kind's diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index 0183c547f9..36928d0b79 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -1883,6 +1883,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } if (event.node.id === draftNode.current?.id) return has3DPointerDrivenMoveRef.current = true + // An authored object's underside takes ceiling items (a pendant under a vault). + if (enterFaceHost(event)) return const result = itemSurfaceStrategy.enter(surfaceContext(event, generic), event) if (!result) return feedback.clear() @@ -1900,6 +1902,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const onItemMove = (event: NodeEvent, generic = false) => { if (event.node.id === draftNode.current?.id) return + if ( + placementState.current.surface === 'block-face' && + placementState.current.blockId === event.node.id + ) { + onFaceHostMove(event) + return + } + if (placementState.current.surface !== 'block-face' && enterFaceHost(event)) { + releaseCommit = () => onFaceHostClick(event) + return + } releaseCommit = () => onItemClick(event) has3DPointerDrivenMoveRef.current = true if (!cursorGroupRef.current) return @@ -2000,6 +2013,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const onItemLeave = (event: NodeEvent) => { if (event.node.type === 'cabinet') return if (event.node.id === draftNode.current?.id) return + if ( + placementState.current.surface === 'block-face' && + placementState.current.blockId === event.node.id + ) { + onFaceHostLeave(event) + return + } if (placementState.current.surface !== 'item-surface') return event.stopPropagation() @@ -2015,6 +2035,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const onItemClick = (event: NodeEvent) => { gridDispatch.flush() if (feedback.reason) return + if ( + placementState.current.surface === 'block-face' && + placementState.current.blockId === event.node.id + ) { + onFaceHostClick(event) + return + } // Click on the draft item itself. R3F dispatches click events to // the closest intersected mesh only — when the draft is hovering // on a host (shelf / table / etc.) the draft's mesh is *above* diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 7435b4486f..917f5f35cd 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -577,6 +577,12 @@ export { commitFreshPlacementSubtree, createFreshPlacementSubtree, } from './lib/fresh-planar-placement' +export { + compileAndStoreGeometryScript, + rebuildAuthoredObject, + storedScript, +} from './lib/geometry-script/author' +export { compileGeometryScriptInWorker } from './lib/geometry-script/client' export { exportSceneToGlb, type GlbExportOptions } from './lib/glb-export' export { type EditorGridEvent, diff --git a/packages/editor/src/lib/geometry-script/author.ts b/packages/editor/src/lib/geometry-script/author.ts new file mode 100644 index 0000000000..a0597ced3e --- /dev/null +++ b/packages/editor/src/lib/geometry-script/author.ts @@ -0,0 +1,67 @@ +import { + type AnyNodeId, + type CompiledGeometryScript, + GEOMETRY_SCRIPT_MIME_TYPE, + type GeometryScriptParamValue, + getArtifactStore, + useScene, +} from '@pascal-app/core' +import { addObject, authoredObject, rescriptOpening } from '@pascal-app/core/agent-operations' +import { compileGeometryScriptInWorker } from './client' + +/** + * The editor's compile step for `add_object`: runs the module in the + * worker and stores the GLB and the module text, so the core operation can + * reference both by hash. Without `code`, the node's stored script is rebuilt + * with the new params. + */ +export async function compileAndStoreGeometryScript(input: { + code?: string + nodeId?: string + params?: Record +}): Promise { + const code = input.code ?? (await storedScript(input.nodeId)) + const { glb, ...compiled } = await compileGeometryScriptInWorker({ code, params: input.params }) + const store = getArtifactStore() + await Promise.all([ + store.put(compiled.sha256, glb, 'model/gltf-binary'), + store.put(compiled.script, new TextEncoder().encode(code), GEOMETRY_SCRIPT_MIME_TYPE), + ]) + return compiled +} + +/** The module text of an authored object, read back from the artifact store. */ +export async function storedScript(nodeId: string | undefined): Promise { + if (!nodeId) throw new Error('Pass the code to build a new object') + const node = authoredObject(useScene.getState().nodes, nodeId) + const code = await getArtifactStore().text(node.source.script) + if (code === null) throw new Error(`The script of ${nodeId} could not be read`) + return code +} + +const rebuildGeneration = new Map() + +/** + * Re-runs a scripted node's script with new param values, the inspector's + * and resize handles' path: same code, new artifact, one undo step. A slower earlier rebuild of + * the same node never overwrites a newer one. + */ +export async function rebuildAuthoredObject( + nodeId: string, + params: Record, + /** Where it ends up, when a resize also moves it (a side arrow keeps the opposite edge). */ + position?: [number, number, number], +): Promise { + const generation = (rebuildGeneration.get(nodeId) ?? 0) + 1 + rebuildGeneration.set(nodeId, generation) + const compiled = await compileAndStoreGeometryScript({ nodeId, params }) + if (rebuildGeneration.get(nodeId) !== generation) return + const nodes = useScene.getState().nodes + const opening = nodes[nodeId as AnyNodeId]?.type !== 'item' + const { changes } = opening + ? rescriptOpening(nodes, { nodeId, compiled, position }, { activeLevelId: null }) + : addObject(nodes, { params, nodeId, compiled, position }, { activeLevelId: null }) + for (const { id, data } of changes?.update ?? []) { + useScene.getState().updateNode(id as AnyNodeId, data) + } +} diff --git a/packages/editor/src/lib/geometry-script/client.ts b/packages/editor/src/lib/geometry-script/client.ts new file mode 100644 index 0000000000..5426b9d556 --- /dev/null +++ b/packages/editor/src/lib/geometry-script/client.ts @@ -0,0 +1,152 @@ +import type { GeometryScriptParamValue } from '@pascal-app/core' +import type { GeometryScriptCompileOutput } from '@pascal-app/geometry-script' +import type { GeometryScriptWorkerRequest, GeometryScriptWorkerResponse } from './protocol' + +const COMPILE_TIMEOUT_MS = 20_000 +const START_TIMEOUT_MS = 15_000 + +// Model-written code runs in a worker inside a sandboxed frame: an opaque +// origin (no cookies, storage or access to the editor) whose CSP allows only +// its own scripts and blob workers, so the code cannot reach the network, +// load fonts or images, or open frames. The worker inherits that policy. +const FRAME_CSP = + "default-src 'none'; script-src 'unsafe-inline' 'unsafe-eval' blob:; worker-src blob:" +const FRAME_HTML = `` + +type Pending = { + code: string + resolve: (output: GeometryScriptCompileOutput) => void + reject: (error: Error) => void + timeout: ReturnType +} + +type FrameMessage = + | { frame: true } + | { ready: true } + | { crashed: string } + | GeometryScriptWorkerResponse + +let frame: HTMLIFrameElement | null = null +let started: Promise | null = null +let nextId = 1 +const pending = new Map() +let onFrameMessage: ((message: FrameMessage) => void) | null = null + +function reset(error: Error) { + frame?.remove() + frame = null + started = null + for (const entry of pending.values()) { + clearTimeout(entry.timeout) + entry.reject(error) + } + pending.clear() +} + +if (typeof window !== 'undefined') { + window.addEventListener('message', (event: MessageEvent) => { + if (!frame || event.source !== frame.contentWindow) return + const message = event.data + if ('crashed' in message) return reset(new Error(message.crashed)) + if ('id' in message) { + const entry = pending.get(message.id) + if (!entry) return + pending.delete(message.id) + clearTimeout(entry.timeout) + if (message.ok) withOwnHashes(message.output, entry.code).then(entry.resolve, entry.reject) + else entry.reject(new Error(message.error)) + return + } + onFrameMessage?.(message) + }) +} + +async function sha256(bytes: ArrayBuffer | Uint8Array): Promise { + const hash = await crypto.subtle.digest('SHA-256', bytes as BufferSource) + return Array.from(new Uint8Array(hash), (b) => b.toString(16).padStart(2, '0')).join('') +} + +/** The artifact and script hashes, computed here: nothing the sandboxed code reports about itself is trusted. */ +async function withOwnHashes( + output: GeometryScriptCompileOutput, + code: string, +): Promise { + const [artifact, script] = await Promise.all([ + sha256(output.glb), + sha256(new TextEncoder().encode(code)), + ]) + return { ...output, sha256: artifact, script } +} + +/** Starts the sandbox frame and its compile worker once; a crash or timeout starts a fresh one. */ +function startSandbox(): Promise { + if (started) return started + started = (async () => { + if (typeof document === 'undefined') throw new Error('Geometry scripts compile in a browser') + const { sandboxWorkerSource } = await import('@pascal-app/geometry-script/sandbox') + const iframe = document.createElement('iframe') + iframe.sandbox.add('allow-scripts') + iframe.setAttribute('aria-hidden', 'true') + iframe.tabIndex = -1 + iframe.style.display = 'none' + iframe.srcdoc = FRAME_HTML + frame = iframe + const ready = new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error('The script sandbox did not start')), + START_TIMEOUT_MS, + ) + onFrameMessage = (message) => { + const target = iframe.contentWindow + if (!target) return + if ('frame' in message) + target.postMessage({ type: 'start', source: sandboxWorkerSource }, '*') + if ('ready' in message) { + clearTimeout(timer) + onFrameMessage = null + resolve(target) + } + } + }) + document.body.appendChild(iframe) + return ready + })() + started.catch((error: unknown) => + reset(error instanceof Error ? error : new Error(String(error))), + ) + return started +} + +/** + * Runs a geometry script in the sandbox and returns its GLB + manifest. A + * script that hangs is stopped with its sandbox; the next compile starts fresh. + */ +export async function compileGeometryScriptInWorker(input: { + code: string + params?: Record +}): Promise { + const target = await startSandbox() + const id = nextId++ + const request: GeometryScriptWorkerRequest = { id, code: input.code, params: input.params } + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reset(new Error(`The script ran longer than ${COMPILE_TIMEOUT_MS / 1000} s and was stopped`)) + }, COMPILE_TIMEOUT_MS) + pending.set(id, { code: input.code, resolve, reject, timeout }) + target.postMessage(request, '*') + }) +} diff --git a/packages/editor/src/lib/geometry-script/protocol.ts b/packages/editor/src/lib/geometry-script/protocol.ts new file mode 100644 index 0000000000..df918a7528 --- /dev/null +++ b/packages/editor/src/lib/geometry-script/protocol.ts @@ -0,0 +1,12 @@ +import type { GeometryScriptParamValue } from '@pascal-app/core' +import type { GeometryScriptCompileOutput } from '@pascal-app/geometry-script' + +export type GeometryScriptWorkerRequest = { + id: number + code: string + params?: Record +} + +export type GeometryScriptWorkerResponse = + | { id: number; ok: true; output: GeometryScriptCompileOutput } + | { id: number; ok: false; error: string } diff --git a/packages/editor/src/lib/glb-export.ts b/packages/editor/src/lib/glb-export.ts index cd63ee7d6f..500c7b6322 100644 --- a/packages/editor/src/lib/glb-export.ts +++ b/packages/editor/src/lib/glb-export.ts @@ -1444,15 +1444,19 @@ function bakeAnimationClips( const target = cloneByOriginal.get(original) if (!node || !target) continue + // A window or door built from a script carries its clips like an authored item. + const scripted = (node.type === 'door' || node.type === 'window') && node.source const clip = bakeRegistryAnimationClips(node, target) ?? - (node.type === 'door' - ? bakeDoorClip(id, node, target) - : node.type === 'window' - ? bakeWindowClip(id, node as WindowNode, target) - : node.type === 'item' - ? bakeItemClip(id, target) - : null) + (scripted + ? bakeItemClip(id, target) + : node.type === 'door' + ? bakeDoorClip(id, node, target) + : node.type === 'window' + ? bakeWindowClip(id, node as WindowNode, target) + : node.type === 'item' + ? bakeItemClip(id, target) + : null) if (clip) { const nodeClips = Array.isArray(clip) ? clip : [clip] @@ -1475,44 +1479,47 @@ function bakeRegistryAnimationClips( } /** - * Re-emit a catalog item's ambient clip (e.g. a fan's spin) onto the baked - * subtree. The source clip targets the item GLB's nodes by name (`lamp_018`); - * since every fan shares those names, we rebind each track to the specific - * cloned node's uuid so multiple fans animate independently. The clip is named - * per node (`: loop`) so the baked viewer can drive each one on its own. + * Re-emit an item's clips (a fan's spin, an authored object's motions) onto the + * baked subtree. Source clips target the item GLB's nodes by name (`lamp_018`); + * since every instance shares those names, we rebind each track to the + * specific cloned node's uuid so instances animate independently. Clips are + * named per node (`: `; the viewer drives `: loop`). */ -function bakeItemClip(id: string, itemObject: THREE.Object3D): THREE.AnimationClip | null { - const entry = itemClipRegistry.get(id) - if (!entry) return null +function bakeItemClip(id: string, itemObject: THREE.Object3D): THREE.AnimationClip[] | null { + const entries = itemClipRegistry.get(id) + if (!entries?.length) return null - const tracks: THREE.KeyframeTrack[] = [] // The catalog node names (e.g. "lamp_018") repeat across every instance of the // item, and the glTF export→import roundtrip rebinds clip tracks by node name — - // so a shared name would make all fans share one clip. Uniquify the targeted - // node's name per item once, then bind tracks by its (stable) uuid. + // so a shared name would make all fans share one clip. Uniquify each targeted + // node's name per item once (clips may share targets), then bind by uuid. const renamed = new Map() - for (const track of entry.clip.tracks) { - const dot = track.name.lastIndexOf('.') - if (dot < 0) continue - const targetName = track.name.slice(0, dot) - const property = track.name.slice(dot + 1) - let targetNode = renamed.get(targetName) - if (!targetNode) { - const found = itemObject.getObjectByName(targetName) - if (!found) continue - found.name = `${id}__${targetName}` - renamed.set(targetName, found) - targetNode = found + const clips: THREE.AnimationClip[] = [] + for (const entry of entries) { + const tracks: THREE.KeyframeTrack[] = [] + for (const track of entry.clip.tracks) { + const dot = track.name.lastIndexOf('.') + if (dot < 0) continue + const targetName = track.name.slice(0, dot) + const property = track.name.slice(dot + 1) + let targetNode = renamed.get(targetName) + if (!targetNode) { + const found = itemObject.getObjectByName(targetName) + if (!found) continue + found.name = `${id}__${targetName}` + renamed.set(targetName, found) + targetNode = found + } + const retargeted = track.clone() + retargeted.name = `${targetNode.uuid}.${property}` + tracks.push(retargeted) } - const retargeted = track.clone() - retargeted.name = `${targetNode.uuid}.${property}` - tracks.push(retargeted) + if (tracks.length === 0) continue + const clip = new THREE.AnimationClip(`${id}: ${entry.name}`, entry.clip.duration, tracks) + clip.userData = { loop: entry.loop } + clips.push(clip) } - - if (tracks.length === 0) return null - const clip = new THREE.AnimationClip(`${id}: loop`, entry.clip.duration, tracks) - clip.userData = { loop: entry.loop } - return clip + return clips.length > 0 ? clips : null } /** diff --git a/packages/geometry-script/LICENSE b/packages/geometry-script/LICENSE new file mode 100644 index 0000000000..083fd9e323 --- /dev/null +++ b/packages/geometry-script/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Pascal Group Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/geometry-script/README.md b/packages/geometry-script/README.md new file mode 100644 index 0000000000..e386f63190 --- /dev/null +++ b/packages/geometry-script/README.md @@ -0,0 +1,5 @@ +# @pascal-app/geometry-script + +Compiles AI-authored three.js geometry modules into a GLB artifact plus a Pascal manifest (parts, paint slots, lights, cutouts, surfaces, undersides, clips). Runs in a browser worker, Bun or Node. + +See `wiki/architecture/authored-objects.md`. diff --git a/packages/geometry-script/bunfig.toml b/packages/geometry-script/bunfig.toml new file mode 100644 index 0000000000..eec7d338da --- /dev/null +++ b/packages/geometry-script/bunfig.toml @@ -0,0 +1,4 @@ +preload = ["../../scripts/bun-preload-three.ts"] + +[test] +preload = ["../../scripts/bun-preload-three.ts"] diff --git a/packages/geometry-script/package.json b/packages/geometry-script/package.json new file mode 100644 index 0000000000..11dffecd03 --- /dev/null +++ b/packages/geometry-script/package.json @@ -0,0 +1,57 @@ +{ + "name": "@pascal-app/geometry-script", + "version": "1.0.3", + "description": "Compiles AI-authored three.js geometry scripts into GLB artifacts with a Pascal manifest. Runs in a browser worker, Bun or Node — no DOM, no React.", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./compile": { + "types": "./dist/compile.d.ts", + "import": "./dist/compile.js", + "default": "./dist/compile.js" + }, + "./sandbox": { + "types": "./dist/sandbox.d.ts", + "import": "./dist/sandbox.js", + "default": "./dist/sandbox.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc --build && bun scripts/build-sandbox.ts", + "dev": "tsgo --build --watch", + "check-types": "tsgo --noEmit", + "prepublishOnly": "npm run build", + "test": "bun test" + }, + "dependencies": { + "@pascal-app/core": "^1.0.3", + "three-bvh-csg": "^0.0.18" + }, + "peerDependencies": { + "three": "^0.186" + }, + "devDependencies": { + "@pascal/typescript-config": "*", + "@types/bun": "^1.3.0", + "@types/three": "^0.184.0", + "typescript": "6.0.3" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/pascalorg/editor.git", + "directory": "packages/geometry-script" + }, + "license": "MIT", + "homepage": "https://github.com/pascalorg/editor/tree/main/packages/geometry-script#readme", + "bugs": "https://github.com/pascalorg/editor/issues" +} diff --git a/packages/geometry-script/scripts/build-sandbox.ts b/packages/geometry-script/scripts/build-sandbox.ts new file mode 100644 index 0000000000..a75a13820a --- /dev/null +++ b/packages/geometry-script/scripts/build-sandbox.ts @@ -0,0 +1,32 @@ +// Bundles the sandbox compile worker into one file and publishes it as a +// string module (`@pascal-app/geometry-script/sandbox`), so a host can hand it +// to an opaque-origin frame that cannot fetch anything itself. +import { mkdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' + +const root = join(import.meta.dir, '..') +const result = await Bun.build({ + entrypoints: [join(root, 'src/sandbox-worker.ts')], + target: 'browser', + format: 'iife', + minify: true, + // A classic script has no import.meta; the bundle only reads import.meta.env.DEV. + define: { 'process.env.NODE_ENV': '"production"', 'import.meta': '{}' }, +}) +if (!result.success) { + for (const log of result.logs) console.error(log) + process.exit(1) +} +const [bundle] = result.outputs +if (!bundle || result.outputs.length !== 1) throw new Error('Expected one sandbox bundle') +const source = await bundle.text() +await mkdir(join(root, 'dist'), { recursive: true }) +await writeFile( + join(root, 'dist/sandbox.js'), + `/** The geometry-script compile worker, bundled: start it from a blob in a sandbox. */\nexport const sandboxWorkerSource = ${JSON.stringify(source)}\n`, +) +await writeFile( + join(root, 'dist/sandbox.d.ts'), + '/** The geometry-script compile worker, bundled: start it from a blob in a sandbox. */\nexport declare const sandboxWorkerSource: string\n', +) +console.log(`sandbox worker: ${(source.length / 1024).toFixed(0)} KiB`) diff --git a/packages/geometry-script/src/compile.test.ts b/packages/geometry-script/src/compile.test.ts new file mode 100644 index 0000000000..9d5719e9a0 --- /dev/null +++ b/packages/geometry-script/src/compile.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from 'bun:test' +import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js' +import { compileGeometryScript } from './compile' + +const SASH = ` +import * as THREE from 'three' +export const mount = 'wall' +export default function build() { + const g = new THREE.Group() + const sash = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 0.05), new THREE.MeshStandardMaterial()) + sash.name = 'sash' + g.add(sash) + const times = [0, 1] + g.animations = [new THREE.AnimationClip('open', 1, [new THREE.VectorKeyframeTrack('sash.position', times, [0, 0, 0, 0, 0.7, 0])])] + return g +} +` + +describe('clips', () => { + test('a clip is stored ending on its last pose, not wrapped to its first', async () => { + const { glb } = await compileGeometryScript({ code: SASH }) + const gltf = await new GLTFLoader().parseAsync(glb, '') + const track = gltf.animations[0]!.tracks.find((t) => t.name.endsWith('.position'))! + const start = track.values[1]! + const end = track.values[track.values.length - 2]! + expect(end - start).toBeCloseTo(0.7, 3) + }) +}) diff --git a/packages/geometry-script/src/compile.ts b/packages/geometry-script/src/compile.ts new file mode 100644 index 0000000000..5d355f06a0 --- /dev/null +++ b/packages/geometry-script/src/compile.ts @@ -0,0 +1,802 @@ +import type { + CompiledGeometryScript, + GeometryArtifactManifest, + GeometryScriptMount, + GeometryScriptParamSpec, + GeometryScriptParamValue, +} from '@pascal-app/core' +import { GEOMETRY_MANIFEST_MAX_BYTES, GEOMETRY_SCRIPT_MAX_BYTES } from '@pascal-app/core/schema' +import * as THREE from 'three' +import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js' +import { ConvexGeometry } from 'three/examples/jsm/geometries/ConvexGeometry.js' +import { LoftGeometry } from 'three/examples/jsm/geometries/LoftGeometry.js' +import * as ParametricFunctions from 'three/examples/jsm/geometries/ParametricFunctions.js' +import { ParametricGeometry } from 'three/examples/jsm/geometries/ParametricGeometry.js' +import { RoundedBoxGeometry } from 'three/examples/jsm/geometries/RoundedBoxGeometry.js' +import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUtils.js' +import { ADDITION, Brush, DIFFERENCE, Evaluator, INTERSECTION, SUBTRACTION } from 'three-bvh-csg' +import { type ModuleTable, transformModule } from './transform' + +export type { GeometryScriptMount } + +export type GeometryScriptCompileInput = { + code: string + params?: Record +} + +export type GeometryScriptCompileOutput = CompiledGeometryScript & { glb: ArrayBuffer } + +export const GEOMETRY_SCRIPT_LIMITS = { + triangles: 300_000, + materials: 32, + extent: 60, +} as const + +const csg = { ADDITION, Brush, DIFFERENCE, Evaluator, INTERSECTION, SUBTRACTION } + +const MODULES: ModuleTable = { + three: THREE as unknown as Record, + 'three-bvh-csg': csg, + BufferGeometryUtils: BufferGeometryUtils as unknown as Record, + ConvexGeometry: { ConvexGeometry }, + LoftGeometry: { LoftGeometry }, + ParametricGeometry: { ParametricGeometry }, + ParametricFunctions: ParametricFunctions as unknown as Record, + RoundedBoxGeometry: { RoundedBoxGeometry }, +} + +const LIB = { + BufferGeometryUtils, + ConvexGeometry, + LoftGeometry, + ParametricGeometry, + ParametricFunctions, + RoundedBoxGeometry, + csg, +} + +const MOUNTS = new Set(['floor', 'wall', 'wall-side', 'ceiling']) + +type RawParam = + | GeometryScriptParamValue + | { + default?: GeometryScriptParamValue + value?: GeometryScriptParamValue + min?: number + max?: number + step?: number + unit?: string + label?: string + options?: string[] + } + +function readParamSpecs(raw: unknown): GeometryScriptParamSpec[] { + if (raw == null) return [] + if (typeof raw !== 'object' || Array.isArray(raw)) { + throw new Error('`params` must be an object of { id: default | { default, min, max, step } }') + } + return Object.entries(raw as Record).map(([id, spec]) => { + const described = spec !== null && typeof spec === 'object' && !Array.isArray(spec) + const value = described ? (spec.default ?? spec.value) : spec + if (typeof value !== 'number' && typeof value !== 'boolean' && typeof value !== 'string') { + throw new Error(`Param "${id}" needs a number, boolean or string default`) + } + if (typeof value === 'number' && !Number.isFinite(value)) { + throw new Error(`Param "${id}" default is not finite`) + } + const kind = typeof value as GeometryScriptParamSpec['kind'] + return described + ? { + id, + kind, + default: value, + label: spec.label, + min: spec.min, + max: spec.max, + step: spec.step, + unit: spec.unit, + options: spec.options, + } + : { id, kind, default: value } + }) +} + +function resolveParams( + specs: GeometryScriptParamSpec[], + overrides: Record, +): Record { + const values: Record = {} + for (const spec of specs) { + const override = overrides[spec.id] + let value = typeof override === typeof spec.default ? override! : spec.default + if (typeof value === 'number') { + if (spec.min !== undefined) value = Math.max(spec.min, value) + if (spec.max !== undefined) value = Math.min(spec.max, value) + } + values[spec.id] = value + } + return values +} + +const conventionId = (name: string, prefix: string): string | null => { + if (!name.startsWith(prefix)) return null + const id = name.slice(prefix.length).trim() + return id.length > 0 ? id : null +} + +const slugify = (value: string) => + value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + +const isHelper = (object: THREE.Object3D) => object.name === 'cutout' || object.name === 'collider' + +function triangleCount(geometry: THREE.BufferGeometry): number { + const index = geometry.getIndex() + return Math.floor((index ? index.count : (geometry.getAttribute('position')?.count ?? 0)) / 3) +} + +function assertFinite(geometry: THREE.BufferGeometry, label: string) { + const position = geometry.getAttribute('position') + if (!position) throw new Error(`${label} has no position attribute`) + const array = position.array as ArrayLike + for (let i = 0; i < array.length; i++) { + if (!Number.isFinite(array[i]!)) throw new Error(`${label} has non-finite vertex positions`) + } +} + +/** Bounds of the visible geometry: helpers (cutout, collider) and lights do not count. */ +function visibleBounds(root: THREE.Object3D): THREE.Box3 { + const box = new THREE.Box3() + const meshBox = new THREE.Box3() + root.updateWorldMatrix(true, true) + root.traverse((object) => { + const mesh = object as THREE.Mesh + if (!mesh.isMesh || isHelper(mesh)) return + let hidden = false + for (let p: THREE.Object3D | null = mesh; p; p = p.parent) if (isHelper(p)) hidden = true + if (hidden) return + mesh.geometry.computeBoundingBox() + meshBox.copy(mesh.geometry.boundingBox!).applyMatrix4(mesh.matrixWorld) + box.union(meshBox) + }) + return box +} + +function originFor(box: THREE.Box3, mount: GeometryScriptMount): THREE.Vector3 { + const center = box.getCenter(new THREE.Vector3()) + if (mount === 'wall-side') return new THREE.Vector3(center.x, box.min.y, box.min.z) + return new THREE.Vector3(center.x, box.min.y, center.z) +} + +const round = (v: number) => Math.round(v * 1e6) / 1e6 +const vec = (v: THREE.Vector3): [number, number, number] => [round(v.x), round(v.y), round(v.z)] + +/** + * Reads the naming conventions into the manifest and rewrites the output so + * they survive glTF: GLTFLoader strips ':' from node names, so convention ids + * move to `userData.pascal`, materials get `slot_` names, and lights + * become manifest entries the item light system drives. + */ +function readConventions(root: THREE.Object3D) { + const parts: GeometryArtifactManifest['parts'] = [] + const anchors: GeometryArtifactManifest['anchors'] = [] + const lights: GeometryArtifactManifest['lights'] = [] + const slots = new Map() + const materialSlot = new Map() + const toRemove: THREE.Object3D[] = [] + let cutout = false + let collider = false + let triangles = 0 + const world = new THREE.Vector3() + + root.updateWorldMatrix(true, true) + root.traverse((object) => { + const userData = object.userData as Record + const partId = conventionId(object.name, 'part:') + if (partId) { + if (parts.some((p) => p.id === partId)) throw new Error(`Duplicate part id "${partId}"`) + parts.push({ + id: partId, + label: typeof userData.label === 'string' ? userData.label : undefined, + type: typeof userData.type === 'string' ? slugify(userData.type) || undefined : undefined, + }) + userData.pascal = { ...(userData.pascal as object), part: partId } + object.name = `part_${partId}` + } + const anchorId = conventionId(object.name, 'anchor:') + if (anchorId) { + const normal = Array.isArray(userData.normal) ? userData.normal : undefined + anchors.push({ + id: anchorId, + position: vec(object.getWorldPosition(world)), + normal: normal?.length === 3 ? (normal as [number, number, number]) : undefined, + }) + toRemove.push(object) + return + } + const light = object as THREE.PointLight + const lightId = + conventionId(object.name, 'light:') ?? (light.isLight ? `light_${lights.length + 1}` : null) + if (lightId) { + const color = + typeof userData.color === 'string' + ? userData.color + : light.isLight + ? `#${light.color.getHexString()}` + : '#fff4e0' + const intensity = + typeof userData.intensity === 'number' + ? userData.intensity + : light.isLight + ? light.intensity + : 1 + const distance = + typeof userData.distance === 'number' + ? userData.distance + : light.isLight && light.distance > 0 + ? light.distance + : undefined + lights.push({ + id: lightId, + position: vec(object.getWorldPosition(world)), + color, + intensity, + distance, + }) + toRemove.push(object) + return + } + if (object.name === 'cutout') cutout = true + if (object.name === 'collider') collider = true + + const mesh = object as THREE.Mesh + if (!mesh.isMesh) return + assertFinite(mesh.geometry, mesh.name || 'A mesh') + if (isHelper(mesh)) return + triangles += triangleCount(mesh.geometry) + const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material] + for (const material of materials) { + if (materialSlot.has(material)) continue + const authored = material.name ?? '' + if (authored.toLowerCase() === 'glass') { + materialSlot.set(material, 'glass') + continue + } + const slotId = + slugify(conventionId(authored, 'slot_') ?? authored) || `material_${slots.size + 1}` + material.name = `slot_${slotId}` + materialSlot.set(material, slotId) + if (!slots.has(slotId)) + slots.set(slotId, authored.startsWith('slot_') ? undefined : authored || undefined) + } + }) + for (const object of toRemove) object.parent?.remove(object) + + return { + parts, + anchors, + lights, + slots: [...slots].map(([id, label]) => ({ id, label })), + cutout, + collider, + triangles, + materialCount: materialSlot.size, + } +} + +const SURFACE_MIN_NORMAL_Y = 0.95 +/** Faces pointing at least this far down hang ceiling items (a vault plane up to ~70°). */ +const UNDERSIDE_MAX_NORMAL_Y = -0.35 +const SURFACE_MIN_AREA = 0.04 +const SURFACE_MAX_COUNT = 32 + +function partOf(object: THREE.Object3D): string | undefined { + for (let p: THREE.Object3D | null = object; p; p = p.parent) { + const part = (p.userData.pascal as { part?: string } | undefined)?.part + if (part) return part + } + return undefined +} + +function convexHull(points: [number, number][]): [number, number][] { + const sorted = [...points].sort((a, b) => a[0] - b[0] || a[1] - b[1]) + if (sorted.length < 3) return sorted + const cross = (o: [number, number], a: [number, number], b: [number, number]) => + (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]) + const lower: [number, number][] = [] + for (const point of sorted) { + while (lower.length >= 2 && cross(lower.at(-2)!, lower.at(-1)!, point) <= 0) lower.pop() + lower.push(point) + } + const upper: [number, number][] = [] + for (const point of sorted.reverse()) { + while (upper.length >= 2 && cross(upper.at(-2)!, upper.at(-1)!, point) <= 0) upper.pop() + upper.push(point) + } + return [...lower.slice(0, -1), ...upper.slice(0, -1)] +} + +/** + * Per-part bounds and the upward-facing flat areas things can rest on (a + * landing, a seat, a step), so a placement tool can drop an object onto real + * geometry without a raycast. Outlines are convex hulls per mesh and height. + */ +function analyseGeometry(root: THREE.Object3D, parts: GeometryArtifactManifest['parts']) { + const partBounds = new Map() + const surfaces = new Map< + string, + { part?: string; y: number; area: number; points: [number, number][] } + >() + const undersides = new Map< + string, + { part?: string; normal: THREE.Vector3; d: number; area: number; points: [number, number][] } + >() + const a = new THREE.Vector3() + const b = new THREE.Vector3() + const c = new THREE.Vector3() + const ab = new THREE.Vector3() + const ac = new THREE.Vector3() + const box = new THREE.Box3() + + root.updateWorldMatrix(true, true) + root.traverse((object) => { + const mesh = object as THREE.Mesh + if (!mesh.isMesh || isHelper(mesh)) return + const part = partOf(mesh) + mesh.geometry.computeBoundingBox() + box.copy(mesh.geometry.boundingBox!).applyMatrix4(mesh.matrixWorld) + if (part) partBounds.set(part, (partBounds.get(part) ?? new THREE.Box3()).union(box)) + + const position = mesh.geometry.getAttribute('position') + const index = mesh.geometry.getIndex() + const count = index ? index.count : position.count + for (let i = 0; i + 2 < count; i += 3) { + const ia = index ? index.getX(i) : i + const ib = index ? index.getX(i + 1) : i + 1 + const ic = index ? index.getX(i + 2) : i + 2 + a.fromBufferAttribute(position, ia).applyMatrix4(mesh.matrixWorld) + b.fromBufferAttribute(position, ib).applyMatrix4(mesh.matrixWorld) + c.fromBufferAttribute(position, ic).applyMatrix4(mesh.matrixWorld) + const normal = ab.subVectors(b, a).cross(ac.subVectors(c, a)) + const area = normal.length() / 2 + if (area === 0) continue + const ny = normal.y / (2 * area) + if (ny <= UNDERSIDE_MAX_NORMAL_Y) { + const unit = normal.clone().normalize() + const key = `${mesh.uuid}|${unit + .toArray() + .map((v) => v.toFixed(2)) + .join(',')}` + const entry = undersides.get(key) ?? { + part, + normal: unit, + d: -unit.dot(a), + area: 0, + points: [], + } + entry.area += area + entry.points.push([a.x, a.z], [b.x, b.z], [c.x, c.z]) + undersides.set(key, entry) + continue + } + if (ny < SURFACE_MIN_NORMAL_Y) continue + const y = Math.round(((a.y + b.y + c.y) / 3) * 100) / 100 + // One outline per mesh and height: a hull across meshes would merge a beam + // and its returns into one surface covering the whole object. + const key = `${mesh.uuid}|${y}` + const entry = surfaces.get(key) ?? { part, y, area: 0, points: [] } + entry.area += area + entry.points.push([a.x, a.z], [b.x, b.z], [c.x, c.z]) + surfaces.set(key, entry) + } + }) + + for (const part of parts) { + const bounds = partBounds.get(part.id) + if (bounds && !bounds.isEmpty()) part.bounds = { min: vec(bounds.min), max: vec(bounds.max) } + } + const hull = (points: [number, number][]) => + convexHull(points).map( + ([x, z]) => [Math.round(x * 1000) / 1000, Math.round(z * 1000) / 1000] as [number, number], + ) + return { + surfaces: [...surfaces.values()] + .filter((surface) => surface.area >= SURFACE_MIN_AREA) + .sort((x, y) => y.area - x.area) + .slice(0, SURFACE_MAX_COUNT) + .map((surface) => ({ part: surface.part, y: surface.y, polygon: hull(surface.points) })), + undersides: [...undersides.values()] + .filter((underside) => underside.area >= SURFACE_MIN_AREA) + .sort((x, y) => y.area - x.area) + .slice(0, SURFACE_MAX_COUNT) + .map((underside) => ({ + part: underside.part, + polygon: hull(underside.points), + plane: [ + ...underside.normal.toArray().map((v: number) => Math.round(v * 1e5) / 1e5), + Math.round(underside.d * 1e5) / 1e5, + ] as [number, number, number, number], + })), + } +} + +/** + * Merges each part's meshes by material, so a porch draws as a few dozen + * meshes instead of hundreds (draw calls, not triangles, are the cost). Part + * groups keep their names and extras; helpers (cutout, collider) and + * multi-material meshes stay as authored. + */ +function mergeByPartAndMaterial(root: THREE.Object3D, animated: Set) { + const owner = (mesh: THREE.Object3D): THREE.Object3D => { + for (let p = mesh.parent; p; p = p.parent) { + if ((p.userData.pascal as { part?: string } | undefined)?.part || p === root) return p + } + return root + } + root.updateWorldMatrix(true, true) + const buckets = new Map< + string, + { owner: THREE.Object3D; material: THREE.Material; meshes: THREE.Mesh[] } + >() + root.traverse((object) => { + const mesh = object as THREE.Mesh + if (!mesh.isMesh || isHelper(mesh) || Array.isArray(mesh.material)) return + const group = owner(mesh) + // A mesh that moves on its own (or under a moving group) keeps its node. + for (let p: THREE.Object3D | null = mesh; p && p !== group; p = p.parent) { + if (animated.has(p)) return + } + const attributes = Object.keys(mesh.geometry.attributes).sort().join(',') + const key = `${group.uuid}|${mesh.material.uuid}|${attributes}` + const bucket = buckets.get(key) ?? { owner: group, material: mesh.material, meshes: [] } + bucket.meshes.push(mesh) + buckets.set(key, bucket) + }) + const inverse = new THREE.Matrix4() + for (const { owner: group, material, meshes } of buckets.values()) { + if (meshes.length < 2) continue + inverse.copy(group.matrixWorld).invert() + const geometries = meshes.map((mesh) => { + const geometry = mesh.geometry.index ? mesh.geometry.toNonIndexed() : mesh.geometry.clone() + return geometry.applyMatrix4(new THREE.Matrix4().multiplyMatrices(inverse, mesh.matrixWorld)) + }) + const merged = BufferGeometryUtils.mergeGeometries(geometries) + if (!merged) continue + for (const mesh of meshes) mesh.parent?.remove(mesh) + const combined = new THREE.Mesh(merged, material) + combined.name = `${group.name || 'object'}_${material.name}` + group.add(combined) + } +} + +const ANIMATION_LIMITS = { clips: 32, duration: 120, values: 200_000 } + +/** + * The module's clips (`group.animations`, as in any three.js project), with + * every track rebound to its target's uuid: names like `part:door.quaternion` + * do not survive three's track-name parser, and part objects are renamed for + * glTF. Returns the clips and the objects they animate. + */ +function readAnimations(built: THREE.Object3D, root: THREE.Object3D) { + const clips = (built.animations ?? []).filter(Boolean) + if (clips.length > ANIMATION_LIMITS.clips) { + throw new Error(`${clips.length} animation clips exceeds the ${ANIMATION_LIMITS.clips} limit`) + } + const objects: THREE.Object3D[] = [] + root.traverse((object) => objects.push(object)) + const byLongestName = objects + .filter((object) => object.name) + .sort((a, b) => b.name.length - a.name.length) + const animated = new Set() + const names = new Set() + let values = 0 + for (const clip of clips) { + if (!(clip instanceof THREE.AnimationClip)) { + throw new Error('group.animations must hold THREE.AnimationClip instances') + } + if (names.has(clip.name)) throw new Error(`Two animation clips are named "${clip.name}"`) + names.add(clip.name) + if (!(clip.duration > 0 && clip.duration <= ANIMATION_LIMITS.duration)) { + throw new Error( + `Clip "${clip.name}" lasts ${clip.duration} s; clips run 0–${ANIMATION_LIMITS.duration} s`, + ) + } + for (const track of clip.tracks) { + const target = + objects.find((object) => track.name.startsWith(`${object.uuid}.`)) ?? + byLongestName.find((object) => track.name.startsWith(`${object.name}.`)) + if (!target) { + throw new Error( + `Track "${track.name}" in clip "${clip.name}" targets no object: name the target . or .`, + ) + } + const property = track.name.slice( + (track.name.startsWith(`${target.uuid}.`) ? target.uuid : target.name).length + 1, + ) + track.name = `${target.uuid}.${property}` + animated.add(target) + values += track.values.length + for (const value of track.values) { + if (!Number.isFinite(value)) throw new Error(`Clip "${clip.name}" has non-finite keyframes`) + } + } + } + if (values > ANIMATION_LIMITS.values) { + throw new Error( + `Animations hold ${values} keyframe values; the limit is ${ANIMATION_LIMITS.values}`, + ) + } + return { clips, animated } +} + +const SAMPLE_FPS = 30 +const MAX_SAMPLES = 900 + +/** + * glTF stores only position, quaternion and scale tracks, and the exporter + * silently drops the rest (an Euler `rotation[y]` track, a `position[x]` + * one). So each clip is played once in a mixer and the transforms it + * produces are sampled back as position / quaternion / scale tracks: any + * track that moves objects survives, however the module wrote it. + */ +function sampleTransformClips(root: THREE.Object3D, clips: THREE.AnimationClip[]) { + const mixer = new THREE.AnimationMixer(root) + const sampled: THREE.AnimationClip[] = [] + for (const clip of clips) { + const targets = [ + ...new Set( + clip.tracks + .map((track) => root.getObjectByProperty('uuid', track.name.split('.')[0]!)) + .filter((object): object is THREE.Object3D => Boolean(object)), + ), + ] + const rest = targets.map((object) => ({ + position: object.position.clone(), + quaternion: object.quaternion.clone(), + scale: object.scale.clone(), + })) + const count = Math.min(MAX_SAMPLES, Math.ceil(clip.duration * SAMPLE_FPS) + 1) + const times = Array.from({ length: count }, (_, i) => (clip.duration * i) / (count - 1)) + const values = targets.map(() => ({ + position: [] as number[], + quaternion: [] as number[], + scale: [] as number[], + })) + const action = mixer.clipAction(clip) + // Played once and held: a looping action wraps to its first frame at the + // clip's end, so `open` would be stored ending closed. + action.setLoop(THREE.LoopOnce, 1) + action.clampWhenFinished = true + action.play() + for (const time of times) { + mixer.setTime(time) + targets.forEach((object, k) => { + values[k]!.position.push(...object.position.toArray()) + values[k]!.quaternion.push(...object.quaternion.toArray()) + values[k]!.scale.push(...object.scale.toArray()) + }) + } + action.stop() + mixer.uncacheClip(clip) + const tracks: THREE.KeyframeTrack[] = [] + targets.forEach((object, k) => { + const { position, quaternion, scale } = rest[k]! + object.position.copy(position) + object.quaternion.copy(quaternion) + object.scale.copy(scale) + const moves = (series: number[], base: number[]) => + series.some((value, i) => Math.abs(value - base[i % base.length]!) > 1e-6) + const v = values[k]! + if (moves(v.position, position.toArray())) + tracks.push(new THREE.VectorKeyframeTrack(`${object.uuid}.position`, times, v.position)) + if (moves(v.quaternion, quaternion.toArray())) + tracks.push( + new THREE.QuaternionKeyframeTrack(`${object.uuid}.quaternion`, times, v.quaternion), + ) + if (moves(v.scale, scale.toArray())) + tracks.push(new THREE.VectorKeyframeTrack(`${object.uuid}.scale`, times, v.scale)) + }) + if (tracks.length === 0) { + throw new Error( + `Clip "${clip.name}" moves nothing: only position, rotation and scale animate (material or visibility tracks do not)`, + ) + } + sampled.push(new THREE.AnimationClip(clip.name, clip.duration, tracks)) + } + root.updateWorldMatrix(true, true) + return sampled +} + +// GLTFExporter writes binaries through FileReader, which Bun and Node lack. +function ensureFileReader() { + const g = globalThis as { FileReader?: unknown } + if (g.FileReader) return + g.FileReader = class { + result: ArrayBuffer | string | null = null + onloadend: (() => void) | null = null + readAsArrayBuffer(blob: Blob) { + void blob.arrayBuffer().then((buffer) => { + this.result = buffer + this.onloadend?.() + }) + } + readAsDataURL(blob: Blob) { + void blob.arrayBuffer().then((buffer) => { + this.result = `data:${blob.type};base64,${Buffer.from(buffer).toString('base64')}` + this.onloadend?.() + }) + } + } +} + +async function exportGlb( + root: THREE.Object3D, + animations: THREE.AnimationClip[], +): Promise { + ensureFileReader() + const exporter = new GLTFExporter() + const result = await exporter.parseAsync(root, { binary: true, onlyVisible: false, animations }) + if (!(result instanceof ArrayBuffer)) + throw new Error('GLB export returned JSON instead of binary') + return result +} + +/** Outlines keep at most this many corners; a 64-sided circle reads the same with 16. */ +const MAX_OUTLINE_POINTS = 16 + +function thin(points: T[]): T[] { + if (points.length <= MAX_OUTLINE_POINTS) return points + const step = points.length / MAX_OUTLINE_POINTS + return Array.from({ length: MAX_OUTLINE_POINTS }, (_, i) => points[Math.floor(i * step)]!) +} + +function outlineArea(polygon: [number, number][]): number { + let area = 0 + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + area += polygon[j]![0] * polygon[i]![1] - polygon[i]![0] * polygon[j]![1] + } + return Math.abs(area) / 2 +} + +const manifestBytes = (manifest: GeometryArtifactManifest) => + new TextEncoder().encode(JSON.stringify(manifest)).byteLength + +/** + * The manifest rides inline in the node, so it stays under + * GEOMETRY_MANIFEST_MAX_BYTES: outlines are thinned, then the smallest + * surfaces and undersides go first (placement falls back to the bounds there), + * then trailing part entries. The build itself never fails over its size. + */ +function compactManifest(manifest: GeometryArtifactManifest): GeometryArtifactManifest { + const next = { + ...manifest, + surfaces: manifest.surfaces.map((surface) => ({ ...surface, polygon: thin(surface.polygon) })), + undersides: manifest.undersides.map((underside) => ({ + ...underside, + polygon: thin(underside.polygon), + })), + } + const bySize = (a: { polygon: [number, number][] }, b: { polygon: [number, number][] }) => + outlineArea(b.polygon) - outlineArea(a.polygon) + next.surfaces.sort(bySize) + next.undersides.sort(bySize) + while (manifestBytes(next) > GEOMETRY_MANIFEST_MAX_BYTES) { + const last = (list: { polygon: [number, number][] }[]) => + list.length ? outlineArea(list[list.length - 1]!.polygon) : Number.POSITIVE_INFINITY + if (next.surfaces.length || next.undersides.length) { + if (last(next.surfaces) <= last(next.undersides)) next.surfaces.pop() + else next.undersides.pop() + } else if (next.parts.length) next.parts.pop() + else break + } + return next +} + +async function digest(bytes: BufferSource): Promise { + // An opaque-origin sandbox has no WebCrypto; its host hashes what it receives. + if (!globalThis.crypto?.subtle) return '' + const hash = await crypto.subtle.digest('SHA-256', bytes) + return Array.from(new Uint8Array(hash), (b) => b.toString(16).padStart(2, '0')).join('') +} + +// A deterrent, not the isolation boundary: hosts run this in a locked-down worker or process. +const SOURCE_GUARD = + /\bimport\s*\(|\beval\s*\(|\bFunction\s*\(|\.constructor\s*\(|\bprocess\b|\brequire\s*\(/ + +export async function compileGeometryScript( + input: GeometryScriptCompileInput, +): Promise { + if (new TextEncoder().encode(input.code).byteLength > GEOMETRY_SCRIPT_MAX_BYTES) { + throw new Error(`The script is longer than ${GEOMETRY_SCRIPT_MAX_BYTES / 1024} KiB`) + } + if (SOURCE_GUARD.test(input.code)) { + throw new Error( + 'Geometry scripts cannot use dynamic import, eval, Function constructors, process or require', + ) + } + const body = transformModule(input.code, MODULES) + // Evaluating the model's module is the compiler's job; callers run it in a locked-down worker. + const factory = new Function('__modules', 'THREE', 'lib', body) as ( + modules: ModuleTable, + three: typeof THREE, + lib: typeof LIB, + ) => { build?: unknown; params?: unknown; mount?: unknown } + const exported = factory(MODULES, THREE, LIB) + if (typeof exported.build !== 'function') { + throw new Error('The script must `export default function build({ params, THREE, lib })`') + } + const mount = (exported.mount ?? 'floor') as GeometryScriptMount + if (!MOUNTS.has(mount)) throw new Error(`\`mount\` must be one of ${[...MOUNTS].join(', ')}`) + const specs = readParamSpecs(exported.params) + const params = resolveParams(specs, input.params ?? {}) + + const built = await (exported.build as (ctx: unknown) => unknown)({ + params, + inputs: {}, + THREE, + lib: LIB, + }) + if (!(built instanceof THREE.Object3D)) { + throw new Error('build() must return a THREE.Object3D (usually a THREE.Group)') + } + + const root = new THREE.Group() + root.name = 'pascal_script_root' + root.add(built) + const box = visibleBounds(root) + if (box.isEmpty()) throw new Error('build() returned no visible meshes') + const size = box.getSize(new THREE.Vector3()) + if (Math.max(size.x, size.y, size.z) > GEOMETRY_SCRIPT_LIMITS.extent) { + throw new Error( + `The object is ${vec(size).join(' × ')} m; the limit is ${GEOMETRY_SCRIPT_LIMITS.extent} m per side. Units are metres.`, + ) + } + built.position.sub(originFor(box, mount)) + root.updateWorldMatrix(true, true) + + const read = readAnimations(built, root) + const clips = sampleTransformClips(root, read.clips) + const animated = read.animated + const conventions = readConventions(root) + if (conventions.triangles > GEOMETRY_SCRIPT_LIMITS.triangles) { + throw new Error( + `${conventions.triangles} triangles exceeds the ${GEOMETRY_SCRIPT_LIMITS.triangles} limit`, + ) + } + if (conventions.materialCount > GEOMETRY_SCRIPT_LIMITS.materials) { + throw new Error( + `${conventions.materialCount} materials exceeds the ${GEOMETRY_SCRIPT_LIMITS.materials} limit`, + ) + } + + const { surfaces, undersides } = analyseGeometry(root, conventions.parts) + mergeByPartAndMaterial(root, animated) + const bounds = visibleBounds(root) + const glb = await exportGlb(root, clips) + return { + glb, + sha256: await digest(glb), + script: await digest(new TextEncoder().encode(input.code)), + mount, + params, + manifest: compactManifest({ + bounds: { min: vec(bounds.min), max: vec(bounds.max) }, + params: specs, + parts: conventions.parts, + surfaces, + undersides, + slots: conventions.slots, + anchors: conventions.anchors, + lights: conventions.lights, + animations: clips.map((clip) => ({ + name: clip.name, + duration: Math.round(clip.duration * 1000) / 1000, + })), + cutout: conventions.cutout, + collider: conventions.collider, + triangles: conventions.triangles, + }), + } +} diff --git a/packages/geometry-script/src/index.ts b/packages/geometry-script/src/index.ts new file mode 100644 index 0000000000..9dab756ac4 --- /dev/null +++ b/packages/geometry-script/src/index.ts @@ -0,0 +1,8 @@ +export { + compileGeometryScript, + GEOMETRY_SCRIPT_LIMITS, + type GeometryScriptCompileInput, + type GeometryScriptCompileOutput, + type GeometryScriptMount, +} from './compile' +export { moduleKey, transformModule } from './transform' diff --git a/packages/geometry-script/src/sandbox-window.ts b/packages/geometry-script/src/sandbox-window.ts new file mode 100644 index 0000000000..b4093f5f41 --- /dev/null +++ b/packages/geometry-script/src/sandbox-window.ts @@ -0,0 +1,3 @@ +// three's `typeof window` guard: a worker has no window. Imported before three. +const scope = globalThis as unknown as Record +if (scope.window === undefined) scope.window = globalThis diff --git a/packages/geometry-script/src/sandbox-worker.ts b/packages/geometry-script/src/sandbox-worker.ts new file mode 100644 index 0000000000..4bb16fdf1c --- /dev/null +++ b/packages/geometry-script/src/sandbox-worker.ts @@ -0,0 +1,58 @@ +/// +// The compile worker that runs inside the editor's opaque-origin sandbox frame. +// Built into one self-contained classic script (scripts/build-sandbox.ts): the +// frame receives it as text and starts it from a blob (an opaque origin cannot +// start a module worker), under the frame's CSP (no +// network, fonts, images or nested frames). Removing ambient capabilities +// below is defence in depth on top of that boundary. + +import './sandbox-window' +import { compileGeometryScript } from './compile' + +const scope = globalThis as unknown as Record + +const BLOCKED = [ + 'fetch', + 'XMLHttpRequest', + 'WebSocket', + 'WebTransport', + 'EventSource', + 'importScripts', + 'indexedDB', + 'caches', + 'Worker', + 'SharedWorker', + 'BroadcastChannel', + 'Request', + 'Response', + 'FontFace', +] as const + +const post = self.postMessage.bind(self) + +for (const name of BLOCKED) { + for (let target: object | null = scope; target; target = Object.getPrototypeOf(target)) { + if (Object.hasOwn(target, name)) { + try { + Object.defineProperty(target, name, { + value: undefined, + configurable: false, + writable: false, + }) + } catch {} + } + } +} + +type Request = { id: number; code: string; params?: Record } + +self.addEventListener('message', async (event: MessageEvent) => { + const { id, code, params } = event.data + try { + const output = await compileGeometryScript({ code, params }) + post({ id, ok: true, output }, [output.glb]) + } catch (error) { + post({ id, ok: false, error: error instanceof Error ? error.message : String(error) }) + } +}) +post({ ready: true }) diff --git a/packages/geometry-script/src/three-addons.d.ts b/packages/geometry-script/src/three-addons.d.ts new file mode 100644 index 0000000000..d993133f3a --- /dev/null +++ b/packages/geometry-script/src/three-addons.d.ts @@ -0,0 +1,10 @@ +// three r186 ships LoftGeometry; @types/three is pinned to 0.184 (tsgo OOM on 0.185+). +declare module 'three/examples/jsm/geometries/LoftGeometry.js' { + import { BufferGeometry, type Vector3 } from 'three' + export class LoftGeometry extends BufferGeometry { + constructor( + sections?: Vector3[][], + options?: { closed?: boolean; capStart?: boolean; capEnd?: boolean }, + ) + } +} diff --git a/packages/geometry-script/src/transform.ts b/packages/geometry-script/src/transform.ts new file mode 100644 index 0000000000..86560b04a0 --- /dev/null +++ b/packages/geometry-script/src/transform.ts @@ -0,0 +1,80 @@ +/** + * Turns a three.js module the model wrote (`import … from 'three'`, + * `export const params`, `export default function build`) into a function + * body evaluated with the modules injected. Only the allowlisted modules + * resolve; anything else fails with the list of what is available. + */ + +export type ModuleTable = Record> + +const IMPORT_RE = /^\s*import\s+([\s\S]+?)\s+from\s+['"]([^'"]+)['"]\s*;?/gm +const SIDE_EFFECT_IMPORT_RE = /^\s*import\s+['"][^'"]+['"]\s*;?/gm + +export function moduleKey(specifier: string): string | null { + if (specifier === 'three') return 'three' + if (specifier === 'three-bvh-csg') return 'three-bvh-csg' + const addon = specifier.match(/^three\/(?:addons|examples\/jsm)\/(?:.+\/)?([^/]+?)(?:\.js)?$/) + return addon ? addon[1]! : null +} + +function bindingsFor(clause: string, moduleRef: string): string { + const out: string[] = [] + let rest = clause.trim() + const namespace = rest.match(/^\*\s+as\s+([A-Za-z_$][\w$]*)$/) + if (namespace) return `const ${namespace[1]} = ${moduleRef};` + const named = rest.match(/\{([\s\S]*)\}/) + if (named) { + const specs = named[1]! + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + .map((s) => { + const [imported, local] = s.split(/\s+as\s+/).map((p) => p.trim()) + return local ? `${imported}: ${local}` : imported! + }) + out.push(`const { ${specs.join(', ')} } = ${moduleRef};`) + rest = rest.replace(named[0], '').replace(/,\s*$/, '').replace(/^\s*,/, '').trim() + } + const defaultName = rest.replace(/,$/, '').trim() + if (defaultName) { + out.push(`const ${defaultName} = ${moduleRef}.default ?? ${moduleRef};`) + } + return out.join(' ') +} + +export function transformModule(code: string, modules: ModuleTable): string { + const unknown: string[] = [] + let body = code.replace(IMPORT_RE, (_all, clause: string, specifier: string) => { + const key = moduleKey(specifier) + if (!key || !(key in modules)) { + unknown.push(specifier) + return '' + } + return bindingsFor(clause, `__modules[${JSON.stringify(key)}]`) + }) + body = body.replace(SIDE_EFFECT_IMPORT_RE, '') + if (unknown.length > 0) { + const available = Object.keys(modules) + .map((key) => (key === 'three' || key === 'three-bvh-csg' ? key : `three/addons/…/${key}.js`)) + .join(', ') + throw new Error(`Unsupported import ${unknown.join(', ')}. Available: ${available}.`) + } + body = body + .replace( + /export\s+default\s+(async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)?\s*\(/, + (_m, a, n) => + n ? `const __default = ${a ?? ''}function ${n}(` : `const __default = ${a ?? ''}function (`, + ) + .replace(/export\s+default\s+/, 'const __default = ') + .replace(/export\s+(const|let|var|function|class)\s/g, '$1 ') + .replace(/export\s*\{[^}]*\}\s*;?/g, '') + // A block scope lets `import * as THREE from 'three'` shadow the injected THREE. + return `{ +${body} +return { + build: typeof __default !== 'undefined' ? __default : typeof build !== 'undefined' ? build : undefined, + params: typeof params !== 'undefined' ? params : undefined, + mount: typeof mount !== 'undefined' ? mount : undefined, +}; +}` +} diff --git a/packages/geometry-script/tsconfig.json b/packages/geometry-script/tsconfig.json new file mode 100644 index 0000000000..98b1f97c9d --- /dev/null +++ b/packages/geometry-script/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "@pascal/typescript-config/react-library.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "noEmit": false, + "composite": true, + "incremental": true, + "types": ["bun"] + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index da2d734f6f..c31f5d59e2 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -1,4 +1,5 @@ export { SceneBridge } from './bridge/scene-bridge' export { createSceneOperations, type SceneOperations } from './operations' export { type CreatePascalMcpServerOptions, createPascalMcpServer } from './server' +export type { GeometryScriptHost } from './tools/add-object' export { version } from './version' diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index b07b747c69..0b8adaf8d3 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -5,6 +5,7 @@ import { registerPrompts } from './prompts' import { registerResources } from './resources' import type { SceneStore } from './storage/types' import { registerTools } from './tools' +import type { GeometryScriptHost } from './tools/add-object' import { normalizeToolSchemaDialect } from './tools/normalize-schema-dialect' import { registerVisionTools } from './tools/vision' import { version } from './version' @@ -28,6 +29,8 @@ export type CreatePascalMcpServerOptions = { * Experimental task-based tool registrations are outside this hook. */ executeTool?: PascalMcpToolExecutor + /** Runs and stores `add_object` modules; without it the tool answers `scripts_unavailable`. */ + geometryScripts?: GeometryScriptHost } export function createPascalMcpServer(opts: CreatePascalMcpServerOptions): McpServer { @@ -38,7 +41,7 @@ export function createPascalMcpServer(opts: CreatePascalMcpServerOptions): McpSe if (opts.executeTool) installToolExecutor(server, opts.executeTool) const operations = opts.operations ?? createSceneOperations({ bridge: opts.bridge, store: opts.store }) - registerTools(server, operations) + registerTools(server, operations, opts.geometryScripts) registerVisionTools(server, operations) registerResources(server, operations) registerPrompts(server, operations) diff --git a/packages/mcp/src/tools/add-object.ts b/packages/mcp/src/tools/add-object.ts new file mode 100644 index 0000000000..0a3b605321 --- /dev/null +++ b/packages/mcp/src/tools/add-object.ts @@ -0,0 +1,187 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { + type AddObjectInput, + addObject, + authoredObject, + readSourceResult, +} from '@pascal-app/core/agent-operations' +import { addObjectTool, getSourceTool, isAgentRefusal, refuse } from '@pascal-app/core/agent-tools' +import { + type AnyNode, + type CompiledGeometryScript, + GEOMETRY_SCRIPT_MIME_TYPE, + type GeometryScriptParamValue, +} from '@pascal-app/core/schema' +import type { SceneOperations } from '../operations' +import { DESTRUCTIVE_TOOL_ANNOTATIONS, READ_ONLY_TOOL_ANNOTATIONS } from './annotations' +import { refusalResult, toolError } from './errors' +import { persistencePayload, publishLiveSceneSnapshot } from './live-sync' +import { toPatches } from './shared-tools' + +/** + * How a host runs `add_object`'s module and keeps the result. Running + * model-written code is the host's call: it decides the isolation, and where + * artifacts live for the active scene. + */ +export type GeometryScriptHost = { + compile(input: { + code: string + params?: Record + }): Promise + storeArtifact(input: { + sceneId: string + sha256: string + bytes: Uint8Array + mimeType: string + }): Promise + /** A stored artifact's bytes (an object's script), or null when missing; only for principals who may edit the scene. */ + readArtifact(input: { sceneId: string; sha256: string }): Promise +} + +/** Compiles a module on the host and stores its GLB and text for the scene: the step every scripted tool shares. */ +export async function compileAndStore( + host: GeometryScriptHost, + sceneId: string, + code: string, + params: Record | undefined, +): Promise { + const { glb, ...compiled } = await host.compile({ code, params }) + await Promise.all([ + host.storeArtifact({ + sceneId, + sha256: compiled.sha256, + bytes: glb, + mimeType: 'model/gltf-binary', + }), + host.storeArtifact({ + sceneId, + sha256: compiled.script, + bytes: new TextEncoder().encode(code), + mimeType: GEOMETRY_SCRIPT_MIME_TYPE, + }), + ]) + return compiled +} + +export async function readScript( + host: GeometryScriptHost, + sceneId: string, + bridge: SceneOperations, + nodeId: string, +): Promise { + const node = authoredObject(bridge.getNodes() as Record, nodeId) + const bytes = await host.readArtifact({ sceneId, sha256: node.source.script }) + if (!bytes) throw new Error(`The script of ${nodeId} could not be read`) + return new TextDecoder().decode(bytes) +} + +/** `add_object` on the MCP: the shared contract and operation, with the host's compile in front. */ +export function registerAddObject( + server: McpServer, + bridge: SceneOperations, + host: GeometryScriptHost | undefined, +): void { + server.registerTool( + addObjectTool.name, + { + title: addObjectTool.title, + description: addObjectTool.description, + inputSchema: addObjectTool.input, + annotations: DESTRUCTIVE_TOOL_ANNOTATIONS, + }, + async (input: Record) => { + if (!host) { + return toolError('This Pascal server cannot run geometry scripts.', { + code: 'scripts_unavailable', + }) + } + const scene = bridge.getActiveScene() + if (!scene) { + return toolError('Open or save a scene first: authored objects are stored with a scene.', { + code: 'no_active_scene', + }) + } + const args = input as Omit + let compiled: CompiledGeometryScript + try { + const code = + args.code ?? + (args.nodeId + ? await readScript(host, scene.id, bridge, args.nodeId) + : refuseMissingCode()) + compiled = await compileAndStore(host, scene.id, code, args.params) + } catch (error) { + if (isAgentRefusal(error)) return refusalResult(error) + return toolError(error instanceof Error ? error.message : String(error), { + code: 'script_failed', + }) + } + let outcome: ReturnType + try { + outcome = addObject( + bridge.getNodes() as Record, + { ...args, compiled }, + { activeLevelId: null }, + ) + } catch (error) { + return refusalResult(error) + } + const patches = outcome.changes ? toPatches(outcome.changes) : [] + if (patches.length) bridge.applyPatch(patches) + const payload = { + ...outcome.result, + ...persistencePayload(await publishLiveSceneSnapshot(bridge, addObjectTool.name)), + } + return { + content: [{ type: 'text' as const, text: JSON.stringify(payload) }], + structuredContent: payload, + } + }, + ) +} + +function refuseMissingCode(): never { + refuse( + 'code_required', + 'Pass code to build a new object; params alone rebuild an existing one (nodeId).', + ) +} + +/** `get_source` on the MCP: the object's module text, read back through the host's store. */ +export function registerGetSource( + server: McpServer, + bridge: SceneOperations, + host: GeometryScriptHost | undefined, +): void { + server.registerTool( + getSourceTool.name, + { + title: getSourceTool.title, + description: getSourceTool.description, + inputSchema: getSourceTool.input, + annotations: READ_ONLY_TOOL_ANNOTATIONS, + }, + async ({ nodeId }: { nodeId: string }) => { + if (!host) { + return toolError('This Pascal server cannot read geometry scripts.', { + code: 'scripts_unavailable', + }) + } + const scene = bridge.getActiveScene() + if (!scene) return toolError('Open a scene first.', { code: 'no_active_scene' }) + try { + const node = authoredObject(bridge.getNodes() as Record, nodeId) + const payload = readSourceResult(node, await readScript(host, scene.id, bridge, nodeId)) + return { + content: [{ type: 'text' as const, text: JSON.stringify(payload) }], + structuredContent: payload, + } + } catch (error) { + if (isAgentRefusal(error)) return refusalResult(error) + return toolError(error instanceof Error ? error.message : String(error), { + code: 'script_unreadable', + }) + } + }, + ) +} diff --git a/packages/mcp/src/tools/index.ts b/packages/mcp/src/tools/index.ts index bb3b49873f..742994b29d 100644 --- a/packages/mcp/src/tools/index.ts +++ b/packages/mcp/src/tools/index.ts @@ -1,5 +1,6 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { SceneOperations } from '../operations' +import { type GeometryScriptHost, registerAddObject, registerGetSource } from './add-object' import { registerApplyPatch } from './apply-patch' import { registerCheckCollisions } from './check-collisions' import { registerConstructionTools } from './construction-tools' @@ -38,14 +39,20 @@ import { registerVariantTools } from './variants' * Scene-lifecycle tools (save/load/list/delete/rename scene) are registered * when persistence operations are available. */ -export function registerTools(server: McpServer, operations: SceneOperations): void { +export function registerTools( + server: McpServer, + operations: SceneOperations, + geometryScripts?: GeometryScriptHost, +): void { registerGetScene(server, operations) registerDescribeNode(server, operations) registerFindNodes(server, operations) registerSharedTools(server, operations) + registerAddObject(server, operations, geometryScripts) + registerGetSource(server, operations, geometryScripts) registerMeasure(server, operations) registerConstructionTools(server, operations) - registerRoomTools(server, operations) + registerRoomTools(server, operations, geometryScripts) registerApplyPatch(server, operations) registerCreateLevel(server, operations) registerCreateUnit(server, operations) diff --git a/packages/mcp/src/tools/place-item.ts b/packages/mcp/src/tools/place-item.ts index 90d53f7515..8cd920c3ff 100644 --- a/packages/mcp/src/tools/place-item.ts +++ b/packages/mcp/src/tools/place-item.ts @@ -1,4 +1,10 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { + flushMountRotation, + geometrySurfaceAt, + geometryUndersideAt, + mountsFlush, +} from '@pascal-app/core' import { projectWorldPointToWallLocalX, wallLength } from '@pascal-app/core/agent-operations' import type { AnyNodeId } from '@pascal-app/core/schema' import { ItemNode } from '@pascal-app/core/schema' @@ -21,6 +27,7 @@ export const placeItemInput = { export const placeItemOutput = { itemId: z.string(), status: z.string().optional(), + restingOn: z.string().optional(), ...liveSyncOutput, } @@ -30,7 +37,7 @@ export function registerPlaceItem(server: McpServer, bridge: SceneOperations): v { title: 'Place item', description: - 'Place a catalog item into the scene. Target a level/slab/zone for floor items, a wall for wall-attached items, or a ceiling for ceiling-attached items. Do not target the site node directly.', + 'Place a catalog item into the scene. Target a level/slab/zone for floor items, a wall for wall-attached items, a ceiling for ceiling-attached items, or an item to rest on it (position in level coordinates; on an object built with add_object it lands on the real surface below the point, such as a porch landing, and a ceiling item hangs from the underside above it, such as a vaulted ceiling, unless position[1] is set above 0). Do not target the site node directly.', inputSchema: placeItemInput, outputSchema: placeItemOutput, annotations: ADDITIVE_TOOL_ANNOTATIONS, @@ -46,11 +53,12 @@ export function registerPlaceItem(server: McpServer, bridge: SceneOperations): v targetType !== 'slab' && targetType !== 'zone' && targetType !== 'wall' && - targetType !== 'ceiling' + targetType !== 'ceiling' && + targetType !== 'item' ) { throwMcpError( ErrorCode.InvalidRequest, - `Cannot place item on ${targetType}; target must be a level, slab, zone, wall, or ceiling. Site-level placement is not supported yet because site.children is reserved for buildings.`, + `Cannot place item on ${targetType}; target must be a level, slab, zone, wall, ceiling or item. Site-level placement is not supported yet because site.children is reserved for buildings.`, ) } @@ -93,9 +101,51 @@ export function registerPlaceItem(server: McpServer, bridge: SceneOperations): v }) } + let restingOn: string | undefined + let tilt: [number, number, number] | undefined + if (target.type === 'item') { + const host = bridge.getNode(target.parentId as AnyNodeId) + if (host?.type !== 'level') { + throwMcpError( + ErrorCode.InvalidRequest, + `Item ${targetNodeId} rests on a ${host?.type ?? 'missing parent'}; only items standing on a level can host another item here.`, + ) + } + // Level coordinates → the host item's frame (translation + yaw). + const [hx, hy, hz] = target.position + const yaw = target.rotation[1] ?? 0 + const dx = requestedPosition[0] - hx + const dz = requestedPosition[2] - hz + const lx = (Math.cos(yaw) * dx - Math.sin(yaw) * dz) / target.scale[0] + const lz = (Math.sin(yaw) * dx + Math.cos(yaw) * dz) / target.scale[2] + const hanging = baseAsset.attachTo === 'ceiling' && target.source + const surface = target.source + ? hanging + ? geometryUndersideAt(target.source.manifest, lx, lz) + : geometrySurfaceAt(target.source.manifest, lx, lz) + : null + const explicitY = !hanging && requestedPosition[1] > 0 + restingOn = explicitY ? undefined : surface?.part + // A ceiling item hangs below the underside (its top flush); others rest on top. + const flush = Boolean(hanging) && mountsFlush(baseAsset) + const drop = hanging ? (flush ? 0.02 : (baseAsset.dimensions?.[1] ?? 0)) : 0 + const ly = explicitY + ? requestedPosition[1] - hy + : surface + ? surface.y * target.scale[1] - drop + : (target.asset.surface?.height ?? target.asset.dimensions[1]) * target.scale[1] + itemPosition = [lx * target.scale[0], ly, lz * target.scale[2]] + // A recessed fixture tilts with a sloped underside (a can in a vault plane). + // Its turn is relative to the host's. + tilt = + flush && surface && 'normal' in surface + ? flushMountRotation(surface.normal, (rotation ?? 0) - yaw) + : [0, (rotation ?? 0) - yaw, 0] + } + const item = ItemNode.parse({ position: itemPosition, - rotation: [0, rotation ?? 0, 0], + rotation: tilt ?? [0, rotation ?? 0, 0], asset: baseAsset, ...wallExtras, }) @@ -104,6 +154,7 @@ export function registerPlaceItem(server: McpServer, bridge: SceneOperations): v const payload = { itemId: id as string, status: catalogAsset ? 'ok' : 'catalog_unavailable', + ...(restingOn ? { restingOn } : {}), ...persistencePayload(persistence), } return { diff --git a/packages/mcp/src/tools/read-tool-annotations.test.ts b/packages/mcp/src/tools/read-tool-annotations.test.ts index 08edfcc006..906d2d29d6 100644 --- a/packages/mcp/src/tools/read-tool-annotations.test.ts +++ b/packages/mcp/src/tools/read-tool-annotations.test.ts @@ -22,6 +22,7 @@ const TOOL_POLICIES = [ 'export_glb', 'export_json', 'find_nodes', + 'find_by_type', 'get_level_summary', 'get_node', 'get_scene', @@ -32,6 +33,7 @@ const TOOL_POLICIES = [ 'list_templates', 'list_units', 'measure', + 'get_source', 'search_assets', 'validate_design', 'validate_scene', @@ -85,6 +87,7 @@ const TOOL_POLICIES = [ }, tools: [ 'apply_patch', + 'add_object', 'create_from_template', 'create_house_from_brief', 'create_stair_between_levels', @@ -156,7 +159,7 @@ describe('MCP tool annotations', () => { try { const listed = await client.listTools() const byName = new Map(listed.tools.map((tool) => [tool.name, tool])) - expect(byName.size).toBe(65) + expect(byName.size).toBe(68) expect([...byName.keys()].toSorted()).toEqual(EXPECTED_TOOL_NAMES) for (const policy of TOOL_POLICIES) { diff --git a/packages/mcp/src/tools/room-tools.ts b/packages/mcp/src/tools/room-tools.ts index 6bb3025b3d..d7e727e5bd 100644 --- a/packages/mcp/src/tools/room-tools.ts +++ b/packages/mcp/src/tools/room-tools.ts @@ -10,22 +10,26 @@ import { type PlanAabb, polygonArea, polygonBounds, + rescriptOpening, type Vec2, } from '@pascal-app/core/agent-operations' -import { addDoorTool, addWindowTool } from '@pascal-app/core/agent-tools' +import { addDoorTool, addWindowTool, isAgentRefusal } from '@pascal-app/core/agent-tools' import { planWallOpening } from '@pascal-app/core/building' import type { AnyNode, AnyNodeId, AssetInput, + CompiledGeometryScript, + GeometryScriptParamValue, WallNode as WallNodeType, } from '@pascal-app/core/schema' import { ItemNode } from '@pascal-app/core/schema' import { z } from 'zod' import type { SceneOperations } from '../operations' +import { compileAndStore, type GeometryScriptHost, readScript } from './add-object' import { ADDITIVE_TOOL_ANNOTATIONS, READ_ONLY_TOOL_ANNOTATIONS } from './annotations' import { findCatalogItem, searchCatalogItems } from './asset-catalog' -import { ErrorCode, refusalResult, throwMcpError } from './errors' +import { ErrorCode, refusalResult, throwMcpError, toolError } from './errors' import { type LiveSyncStatus, liveSyncOutput, @@ -34,6 +38,7 @@ import { } from './live-sync' import { measurement } from './measurement' import { NodeIdSchema, Vec2Schema } from './schemas' +import { toPatches } from './shared-tools' const ROOM_TYPES = [ 'bedroom', @@ -536,7 +541,95 @@ export function registerCreateRoom(server: McpServer, bridge: SceneOperations): ) } -export function registerAddDoor(server: McpServer, bridge: SceneOperations): void { +/** + * `add_door` / `add_window` with a nodeId: rebuild that opening from new code, + * or its stored script with new params, through the shared operation. + */ +async function rebuildOpening( + kind: 'door' | 'window', + bridge: SceneOperations, + host: GeometryScriptHost | undefined, + input: { + nodeId: string + code?: string + params?: Record + }, +) { + if (!host) + return toolError('This Pascal server cannot run geometry scripts.', { + code: 'scripts_unavailable', + }) + const scene = bridge.getActiveScene() + if (!scene) return toolError('Open or save a scene first.', { code: 'no_active_scene' }) + const nodes = bridge.getNodes() as Record + let outcome: ReturnType + try { + const code = input.code ?? (await readScript(host, scene.id, bridge, input.nodeId)) + const compiled = await compileAndStore(host, scene.id, code, input.params) + outcome = rescriptOpening(nodes, { nodeId: input.nodeId, compiled }, { activeLevelId: null }) + } catch (error) { + if (isAgentRefusal(error)) return refusalResult(error) + return toolError(error instanceof Error ? error.message : String(error), { + code: 'script_failed', + }) + } + if (outcome.changes) bridge.applyPatch(toPatches(outcome.changes)) + const node = bridge.getNodes()[input.nodeId as AnyNodeId] as AnyNode & { + position: [number, number, number] + height: number + wallId?: string + } + const wall = node.wallId + ? (bridge.getNodes()[node.wallId as AnyNodeId] as WallNodeType) + : undefined + const wallLength = wall ? Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) : 0 + const persistence = await publishLiveSceneSnapshot(bridge, `add_${kind}`) + return textResult({ + [kind === 'door' ? 'doorId' : 'windowId']: input.nodeId, + localX: node.position[0], + t: wallLength ? node.position[0] / wallLength : 0, + position: wallLength ? node.position[0] / wallLength : 0, + wallLength, + clamped: false, + coordinateSystem: 'wall-local-meters' as const, + ...(kind === 'window' ? { sillHeight: node.position[1] - node.height / 2 } : {}), + ...outcome.result, + ...persistencePayload(persistence), + }) +} + +/** A door or window passed `code`: compiled and stored the way add_object does, or the tool's error. */ +async function compileOpeningScript( + bridge: SceneOperations, + host: GeometryScriptHost | undefined, + input: { code?: string; params?: Record }, +): Promise<{ script?: CompiledGeometryScript } | { error: ReturnType }> { + if (!input.code) return {} + if (!host) + return { + error: toolError('This Pascal server cannot run geometry scripts; use the fields.', { + code: 'scripts_unavailable', + }), + } + const scene = bridge.getActiveScene() + if (!scene) + return { error: toolError('Open or save a scene first.', { code: 'no_active_scene' }) } + try { + return { script: await compileAndStore(host, scene.id, input.code, input.params) } + } catch (error) { + return { + error: toolError(error instanceof Error ? error.message : String(error), { + code: 'script_failed', + }), + } + } +} + +export function registerAddDoor( + server: McpServer, + bridge: SceneOperations, + geometryScripts?: GeometryScriptHost, +): void { server.registerTool( addDoorTool.name, { @@ -547,11 +640,16 @@ export function registerAddDoor(server: McpServer, bridge: SceneOperations): voi annotations: ADDITIVE_TOOL_ANNOTATIONS, }, async (input) => { + if (input.nodeId) + return rebuildOpening('door', bridge, geometryScripts, { ...input, nodeId: input.nodeId }) + const compiled = await compileOpeningScript(bridge, geometryScripts, input) + if ('error' in compiled) return compiled.error let planned: ReturnType try { planned = planWallOpening(bridge.getNodes() as Record, { kind: 'door', ...input, + compiled: compiled.script, }) } catch (error) { return refusalResult(error) @@ -572,7 +670,11 @@ export function registerAddDoor(server: McpServer, bridge: SceneOperations): voi ) } -export function registerAddWindow(server: McpServer, bridge: SceneOperations): void { +export function registerAddWindow( + server: McpServer, + bridge: SceneOperations, + geometryScripts?: GeometryScriptHost, +): void { server.registerTool( addWindowTool.name, { @@ -583,11 +685,16 @@ export function registerAddWindow(server: McpServer, bridge: SceneOperations): v annotations: ADDITIVE_TOOL_ANNOTATIONS, }, async (input) => { + if (input.nodeId) + return rebuildOpening('window', bridge, geometryScripts, { ...input, nodeId: input.nodeId }) + const compiled = await compileOpeningScript(bridge, geometryScripts, input) + if ('error' in compiled) return compiled.error let planned: ReturnType try { planned = planWallOpening(bridge.getNodes() as Record, { kind: 'window', ...input, + compiled: compiled.script, }) } catch (error) { return refusalResult(error) @@ -733,10 +840,14 @@ export function registerFurnishRoom(server: McpServer, bridge: SceneOperations): ) } -export function registerRoomTools(server: McpServer, bridge: SceneOperations): void { +export function registerRoomTools( + server: McpServer, + bridge: SceneOperations, + geometryScripts?: GeometryScriptHost, +): void { registerSearchAssets(server) registerCreateRoom(server, bridge) - registerAddDoor(server, bridge) - registerAddWindow(server, bridge) + registerAddDoor(server, bridge, geometryScripts) + registerAddWindow(server, bridge, geometryScripts) registerFurnishRoom(server, bridge) } diff --git a/packages/mcp/src/tools/shared-tools.ts b/packages/mcp/src/tools/shared-tools.ts index fc5bcc3ef5..e96b8230f9 100644 --- a/packages/mcp/src/tools/shared-tools.ts +++ b/packages/mcp/src/tools/shared-tools.ts @@ -7,6 +7,7 @@ import { import { deleteNodeTool, duplicateLevelTool, + findByTypeTool, getLevelSummaryTool, getNodeTool, getWallsTool, @@ -54,6 +55,11 @@ const levelRoleOutput = { } const SHARED_TOOLS: SharedTool[] = [ + { + contract: findByTypeTool, + operation: AGENT_OPERATIONS.find_by_type, + annotations: READ_ONLY_TOOL_ANNOTATIONS, + }, { contract: listLevelsTool, operation: AGENT_OPERATIONS.list_levels, @@ -148,7 +154,7 @@ const SHARED_TOOLS: SharedTool[] = [ }, ] -function toPatches(changes: SceneChanges): Patch[] { +export function toPatches(changes: SceneChanges): Patch[] { return [ ...(changes.create ?? []).map(({ node, parentId }) => ({ op: 'create' as const, diff --git a/packages/nodes/src/door/definition.ts b/packages/nodes/src/door/definition.ts index fd63fdaf8d..c2dac86017 100644 --- a/packages/nodes/src/door/definition.ts +++ b/packages/nodes/src/door/definition.ts @@ -18,6 +18,7 @@ import { createOpeningPropertyPreview } from '../shared/opening-property-preview import { openingPropertyPreviewHost } from '../shared/opening-property-preview-host' import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host' import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut' +import { scriptedOpeningHandles } from '../shared/scripted-opening-handles' import { readHostWallCeiling } from '../shared/wall-opening-ceiling' import { wallFloorplanSiblingOverrides } from '../wall/floorplan-overrides' import { buildDoorContextualDimensions } from './contextual-dimensions' @@ -263,7 +264,10 @@ export const doorDefinition: NodeDefinition = { }, parametrics: doorParametrics, - handles: doorHandles, + handles: (node) => + node.source + ? [doorMoveHandle(), ...scriptedOpeningHandles(node, doorWidthHandle, doorHeightHandle)] + : doorHandles, rendersChildren: false, renderer: { diff --git a/packages/nodes/src/door/floorplan.ts b/packages/nodes/src/door/floorplan.ts index 1939068868..0cbfbb3a10 100644 --- a/packages/nodes/src/door/floorplan.ts +++ b/packages/nodes/src/door/floorplan.ts @@ -797,24 +797,27 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp // direction). Pointer-down on either routes through the door's // `resize-width` affordance — anchored at the opposite edge, clamped // to wall bounds. Mirrors the 3D `DoorSideArrow` width drag. - const startEdgeX = cx - dirX * halfWidth - const startEdgeZ = cz - dirZ * halfWidth - const endEdgeX = cx + dirX * halfWidth - const endEdgeZ = cz + dirZ * halfWidth - children.push({ - kind: 'move-arrow', - point: [startEdgeX, startEdgeZ], - angle: Math.atan2(-dirZ, -dirX), - affordance: 'resize-width', - payload: { side: 'start' }, - }) - children.push({ - kind: 'move-arrow', - point: [endEdgeX, endEdgeZ], - angle: Math.atan2(dirZ, dirX), - affordance: 'resize-width', - payload: { side: 'end' }, - }) + // A scripted opening's width is its script's: its size arrows live in 3D, on its params. + if (!node.source) { + const startEdgeX = cx - dirX * halfWidth + const startEdgeZ = cz - dirZ * halfWidth + const endEdgeX = cx + dirX * halfWidth + const endEdgeZ = cz + dirZ * halfWidth + children.push({ + kind: 'move-arrow', + point: [startEdgeX, startEdgeZ], + angle: Math.atan2(-dirZ, -dirX), + affordance: 'resize-width', + payload: { side: 'start' }, + }) + children.push({ + kind: 'move-arrow', + point: [endEdgeX, endEdgeZ], + angle: Math.atan2(dirZ, dirX), + affordance: 'resize-width', + payload: { side: 'end' }, + }) + } } // Placement-measurement dimensions — distances to adjacent openings diff --git a/packages/nodes/src/door/mechanism.ts b/packages/nodes/src/door/mechanism.ts index d261771850..79b281a45f 100644 --- a/packages/nodes/src/door/mechanism.ts +++ b/packages/nodes/src/door/mechanism.ts @@ -1,16 +1,23 @@ import { isOperationDoorType, type MechanismCapability } from '@pascal-app/core' import { closeDoorOpenState, getDisplayedDoorValue, openDoorOpenState } from '@pascal-app/editor' +import { scriptedOpening } from '../shared/scripted-opening' /** A door's leaf: Play opens it and Stop closes it, without touching the saved open state. */ export const doorMechanism: MechanismCapability = { verb: 'open', icon: 'door', - has: (node) => node.type === 'door' && node.openingKind !== 'opening', + has: (node) => + node.type === 'door' && + (node.source ? scriptedOpening.has(node) : node.openingKind !== 'opening'), isOn: (node) => { if (node.type !== 'door') return false + if (node.source) return scriptedOpening.isOn(node) return isOperationDoorType(node.doorType) ? getDisplayedDoorValue(node.id, 'operationState', node.operationState) > 0 : getDisplayedDoorValue(node.id, 'swingAngle', node.swingAngle) > 0 }, - set: (node, on) => (on ? openDoorOpenState : closeDoorOpenState)(node.id, { persist: false }), + set: (node, on) => { + if (node.type === 'door' && node.source) return scriptedOpening.set(node, on) + return (on ? openDoorOpenState : closeDoorOpenState)(node.id, { persist: false }) + }, } diff --git a/packages/nodes/src/door/panel.tsx b/packages/nodes/src/door/panel.tsx index aafbb27e47..698ea6da3c 100644 --- a/packages/nodes/src/door/panel.tsx +++ b/packages/nodes/src/door/panel.tsx @@ -16,6 +16,7 @@ import { import { useViewer } from '@pascal-app/viewer' import { Copy, DoorOpen, FlipHorizontal2, Move, Trash2 } from 'lucide-react' import { useCallback, useEffect, useMemo } from 'react' +import { AuthoredParams } from '../item/authored-params' import { constrainCurtainOpening, curtainOpeningLimits } from '../shared/curtain-opening-limits' import { createOpeningPropertyPreview } from '../shared/opening-property-preview' import { openingPropertyPreviewHost } from '../shared/opening-property-preview-host' @@ -279,6 +280,8 @@ export default function DoorPanel() { const hSum = node.segments.reduce((s, seg) => s + seg.heightRatio, 0) const normHeights = node.segments.map((seg) => seg.heightRatio / hSum) const isOpening = node.openingKind === 'opening' + // Built from a script: its params replace the parametric leaf's fields, as on an authored item. + const scripted = Boolean(node.source) const openingShape = node.openingShape ?? 'rectangle' const doorShape = openingShape === 'arch' || openingShape === 'rounded' ? openingShape : 'rectangle' @@ -304,19 +307,21 @@ export default function DoorPanel() { const supportsHandleSide = doorType === 'hinged' const supportsTopShape = isSwingDoor const showFlipSide = !isCutoutOnly - const showFoldSection = isFoldingDoor && !isCutoutOnly - const showSlideSection = isSlidingDoor && !isCutoutOnly + const showFoldSection = !scripted && isFoldingDoor && !isCutoutOnly + const showSlideSection = !scripted && isSlidingDoor && !isCutoutOnly const showGarageSection = - (isSectionalGarageDoor || isRollupGarageDoor || isTiltupGarageDoor) && !isCutoutOnly - const showOpeningShapeSection = isCutoutOnly - const showDoorShapeSection = !isCutoutOnly && supportsTopShape - const showFrameSection = !isCutoutOnly - const showContentPaddingSection = !isCutoutOnly && !isGarageDoor - const showSwingSection = isSwingDoor - const showThresholdSection = isSwingDoor - const showHandleSection = isSwingDoor - const showHardwareSection = isSwingDoor - const showSegmentsSection = !isCutoutOnly && !isGarageDoor + !scripted && + (isSectionalGarageDoor || isRollupGarageDoor || isTiltupGarageDoor) && + !isCutoutOnly + const showOpeningShapeSection = !scripted && isCutoutOnly + const showDoorShapeSection = !scripted && !isCutoutOnly && supportsTopShape + const showFrameSection = !scripted && !isCutoutOnly + const showContentPaddingSection = !scripted && !isCutoutOnly && !isGarageDoor + const showSwingSection = !scripted && isSwingDoor + const showThresholdSection = !scripted && isSwingDoor + const showHandleSection = !scripted && isSwingDoor + const showHardwareSection = !scripted && isSwingDoor + const showSegmentsSection = !scripted && !isCutoutOnly && !isGarageDoor const maxDoorWidth = isGarageDoor ? 6 : 3 const setOpeningTopRadius = (index: number, value: number, commit = false) => { @@ -493,67 +498,69 @@ export default function DoorPanel() { title={node.name || 'Door'} width={320} > - -
- - handleUpdate( - v === 'opening' - ? { - openingKind: v, - openingShape, - openingRadiusMode, - openingTopRadii, - cornerRadius, - archHeight, - openingRevealRadius, - } - : v === 'garage' + {!scripted && ( + +
+ + handleUpdate( + v === 'opening' ? { - openingKind: 'door', - ...getDoorTypeUpdates(isGarageDoor ? doorType : 'garage-sectional'), + openingKind: v, + openingShape, + openingRadiusMode, + openingTopRadii, + cornerRadius, + archHeight, + openingRevealRadius, } - : { - openingKind: 'door', - ...(isGarageDoor ? getDoorTypeUpdates('hinged') : {}), - }, - ) - } - options={[ - { label: 'Door', value: 'door' }, - { label: 'Opening', value: 'opening' }, - { label: 'Garage', value: 'garage' }, - ]} - value={typeMode} - /> -
- {!isOpening && ( -
- {(isGarageDoor ? garageDoorTypeOptions : doorTypeOptions).map((option) => { - const isSelected = doorType === option.value - return ( - - ) - })} + : v === 'garage' + ? { + openingKind: 'door', + ...getDoorTypeUpdates(isGarageDoor ? doorType : 'garage-sectional'), + } + : { + openingKind: 'door', + ...(isGarageDoor ? getDoorTypeUpdates('hinged') : {}), + }, + ) + } + options={[ + { label: 'Door', value: 'door' }, + { label: 'Opening', value: 'opening' }, + { label: 'Garage', value: 'garage' }, + ]} + value={typeMode} + />
- )} -
+ {!isOpening && ( +
+ {(isGarageDoor ? garageDoorTypeOptions : doorTypeOptions).map((option) => { + const isSelected = doorType === option.value + return ( + + ) + })} +
+ )} + + )} )} - - {limits && ( -

- Size is limited to the wall, including clearance for the opening frame. -

- )} - previewDoorUpdate('width', v)} - onCommit={(v) => commitDoorPreview('width', v)} - onCancel={() => preview?.cancel()} - previewWhileTyping - precision={2} - restoreOnCommit={false} - step={0.01} - unit="m" - value={node.width} - /> - preview?.preview(heightUpdates(v))} - onCommit={(v) => preview?.commit(heightUpdates(v))} - onCancel={() => preview?.cancel()} - previewWhileTyping - precision={2} - restoreOnCommit={false} - step={0.01} - unit="m" - value={node.height} - /> -
+ {scripted && } + + {!scripted && ( + + {limits && ( +

+ Size is limited to the wall, including clearance for the opening frame. +

+ )} + previewDoorUpdate('width', v)} + onCommit={(v) => commitDoorPreview('width', v)} + onCancel={() => preview?.cancel()} + previewWhileTyping + precision={2} + restoreOnCommit={false} + step={0.01} + unit="m" + value={node.width} + /> + preview?.preview(heightUpdates(v))} + onCommit={(v) => preview?.commit(heightUpdates(v))} + onCancel={() => preview?.cancel()} + previewWhileTyping + precision={2} + restoreOnCommit={false} + step={0.01} + unit="m" + value={node.height} + /> +
+ )} {showDoorShapeSection && ( @@ -931,7 +942,7 @@ export default function DoorPanel() { )} - {!isCutoutOnly && ( + {!isCutoutOnly && !scripted && ( <> {showFrameSection && ( diff --git a/packages/nodes/src/door/renderer.tsx b/packages/nodes/src/door/renderer.tsx index 48feda13ab..e4a48bab92 100644 --- a/packages/nodes/src/door/renderer.tsx +++ b/packages/nodes/src/door/renderer.tsx @@ -5,6 +5,7 @@ import { useNodeEvents } from '@pascal-app/viewer' import { useLayoutEffect, useRef } from 'react' import { type Mesh, MeshBasicMaterial } from 'three' import { RoofFaceHostFrame } from '../shared/roof-face-host' +import { ScriptedOpeningModel } from '../shared/scripted-opening' const doorHitboxMaterial = new MeshBasicMaterial({ visible: false }) @@ -34,6 +35,7 @@ export const DoorRenderer = ({ node }: { node: DoorNode }) => { {...(isTransient ? {} : handlers)} > + {node.source && } ) diff --git a/packages/nodes/src/item/authored-face-host.ts b/packages/nodes/src/item/authored-face-host.ts new file mode 100644 index 0000000000..f39ccd4c4f --- /dev/null +++ b/packages/nodes/src/item/authored-face-host.ts @@ -0,0 +1,97 @@ +import { + type FaceHostCapability, + type FaceHostPlacementArgs, + flushMountRotation, + type ItemNode, + mountsFlush, + sceneRegistry, +} from '@pascal-app/core' +import { type BufferGeometry, type Mesh, Quaternion, Triangle, Vector3 } from 'three' + +/** Faces pointing at least this far down take ceiling items (a vault plane at up to ~70°). */ +const UNDERSIDE_MAX_NORMAL_Y = -0.35 +const UNDERSIDE_FACE = 'underside' + +function hitNormal(object: Mesh, faceIndex: number | undefined): Vector3 | null { + const geometry = object.geometry as BufferGeometry | undefined + const position = geometry?.getAttribute('position') + if (!(geometry && position) || faceIndex === undefined) return null + const index = geometry.getIndex() + const vertex = (k: number) => (index ? index.getX(faceIndex * 3 + k) : faceIndex * 3 + k) + const triangle = new Triangle( + new Vector3().fromBufferAttribute(position, vertex(0)), + new Vector3().fromBufferAttribute(position, vertex(1)), + new Vector3().fromBufferAttribute(position, vertex(2)), + ) + return triangle.getNormal(new Vector3()) +} + +/** + * Where a ceiling item hangs under an authored object: the hit point and the + * face normal, both in the host item's frame, when the face points down. + */ +function resolveUnderside(args: FaceHostPlacementArgs) { + if (!args.host.source || args.asset.attachTo !== 'ceiling') return null + const hostObject = sceneRegistry.nodes.get(args.host.id) + const object = args.object as Mesh + const localNormal = hitNormal(object, args.faceIndex) + if (!(hostObject && localNormal)) return null + object.updateWorldMatrix(true, false) + hostObject.updateWorldMatrix(true, false) + const world = object.localToWorld(new Vector3(...args.localPosition)) + const point = hostObject.worldToLocal(world.clone()) + const toHost = hostObject.getWorldQuaternion(new Quaternion()).invert() + const normal = localNormal + .applyQuaternion(object.getWorldQuaternion(new Quaternion())) + .applyQuaternion(toHost) + .normalize() + if (normal.y > UNDERSIDE_MAX_NORMAL_Y) return null + return { world, point, normal } +} + +/** + * Authored objects host ceiling items (pendants, fans, recessed cans) on + * their real undersides — a vault plane, a soffit, a beam — found from the + * pointer's hit, so placement follows the geometry the script built. A + * pendant hangs plumb, a recessed fixture tilts with the slope; either + * becomes the object's child. + */ +export const authoredItemFaceHost: FaceHostCapability = { + currentFaceId: (item) => (item?.asset.attachTo === 'ceiling' ? UNDERSIDE_FACE : null), + clearItemFields: [], + resolvePlacement: (args) => { + const hit = resolveUnderside(args) + if (!hit) return null + const yaw = args.draftItem?.rotation[1] ?? 0 + // A recessed fixture seats flush along the face, tilted with a slope; a + // pendant or fan hangs plumb from the point. + const flush = mountsFlush({ ...args.asset, dimensions: args.rawDimensions }) + const offset = flush + ? hit.normal.clone().multiplyScalar(0.02) + : new Vector3(0, -args.rawDimensions[1], 0) + const at = hit.point.clone().add(offset) + const position: [number, number, number] = [at.x, at.y, at.z] + const rotation: [number, number, number] = flush + ? flushMountRotation([hit.normal.x, hit.normal.y, hit.normal.z], yaw) + : [0, yaw, 0] + const cursor = hit.world.clone().add(offset) + return { + faceId: UNDERSIDE_FACE, + nodeUpdate: { + position, + rotation, + parentId: args.host.id, + wallId: undefined, + blockFaceId: undefined, + roofSegmentId: undefined, + roofFace: undefined, + } satisfies Partial, + position, + rotation, + cursorPosition: cursor.toArray() as [number, number, number], + cursorRotation: rotation, + } + }, + storedPlacementPatch: () => null, + isStoredPlacementValid: ({ host, asset }) => Boolean(host.source) && asset.attachTo === 'ceiling', +} diff --git a/packages/nodes/src/item/authored-params.tsx b/packages/nodes/src/item/authored-params.tsx new file mode 100644 index 0000000000..ed82aafba6 --- /dev/null +++ b/packages/nodes/src/item/authored-params.tsx @@ -0,0 +1,100 @@ +'use client' + +import type { + GeometryScriptParamSpec, + GeometryScriptParamValue, + ScriptedNode, +} from '@pascal-app/core' +import { + PanelSection, + rebuildAuthoredObject, + SegmentedControl, + SliderControl, + ToggleControl, +} from '@pascal-app/editor' +import { useState } from 'react' + +/** + * The params an authored object's script declares, as controls. Sliders + * preview their value while dragging and rebuild on release (a rebuild runs + * the script again: tens to hundreds of milliseconds). + */ +export function AuthoredParams({ + node, +}: { + node: { id: string; source?: ScriptedNode['source'] } +}) { + const source = node.source + const [drafts, setDrafts] = useState>({}) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + if (!source || source.manifest.params.length === 0) return null + + const rebuild = (id: string, value: GeometryScriptParamValue) => { + setBusy(true) + setError(null) + rebuildAuthoredObject(node.id, { ...source.params, [id]: value }) + .catch((reason: unknown) => { + console.error('[authored object] rebuild failed', reason) + setError("Couldn't rebuild with these values.") + }) + .finally(() => { + setBusy(false) + setDrafts((current) => { + const { [id]: _, ...rest } = current + return rest + }) + }) + } + + const control = (spec: GeometryScriptParamSpec) => { + const value = source.params[spec.id] ?? spec.default + const label = spec.label ?? spec.id + if (spec.kind === 'boolean') { + return ( + rebuild(spec.id, checked)} + /> + ) + } + if (spec.kind === 'string') { + if (!spec.options?.length) return null + return ( + rebuild(spec.id, next)} + options={spec.options.map((option) => ({ label: option, value: option }))} + value={String(value)} + /> + ) + } + const current = drafts[spec.id] ?? Number(value) + const span = Math.max(Math.abs(Number(spec.default)), 1) + return ( + setDrafts((d) => ({ ...d, [spec.id]: next }))} + onCommit={(next) => rebuild(spec.id, next)} + precision={spec.step !== undefined && spec.step < 1 ? 2 : 0} + restoreOnCommit={false} + step={spec.step ?? 0.01} + unit={spec.unit} + value={current} + /> + ) + } + + return ( + + {source.manifest.params.map(control)} + {busy ?
Rebuilding…
: null} + {error ?
{error}
: null} +
+ ) +} diff --git a/packages/nodes/src/item/definition.ts b/packages/nodes/src/item/definition.ts index a0634f4e3b..9666cb18d3 100644 --- a/packages/nodes/src/item/definition.ts +++ b/packages/nodes/src/item/definition.ts @@ -10,6 +10,7 @@ import type { FloorplanNodeExtension } from '@pascal-app/editor' import { itemHasLights, itemMechanism, toggleItemLights } from '../shared/item-interactions' import { itemBatchable } from '../shared/node-batch/batchable' import { restingFloorplanAffectedIds } from '../shared/resting-surface-plan' +import { authoredItemFaceHost } from './authored-face-host' import { buildItemContextualDimensions, buildItemFloorplan } from './floorplan' import { itemFloorplanMoveTarget } from './floorplan-move' import { itemPaint } from './paint' @@ -212,6 +213,8 @@ export const itemDefinition: NodeDefinition = { capabilities: { batchable: itemBatchable, selectable: { hitVolume: 'bbox' }, + // Authored objects host ceiling items on their undersides; catalog items do not. + faceHost: authoredItemFaceHost, surfaces: { top: { height: (node) => { diff --git a/packages/nodes/src/item/panel.tsx b/packages/nodes/src/item/panel.tsx index cffbe56435..ff54cf0483 100644 --- a/packages/nodes/src/item/panel.tsx +++ b/packages/nodes/src/item/panel.tsx @@ -14,6 +14,7 @@ import { import { useViewer } from '@pascal-app/viewer' import { Copy, Link, Link2Off, Move, Trash2 } from 'lucide-react' import { useCallback, useRef, useState } from 'react' +import { AuthoredParams } from './authored-params' /** * Stage E inspector for item. 1:1 port of the legacy @@ -81,6 +82,8 @@ export default function ItemPanel() { rotation: [...node.rotation] as [number, number, number], name: node.name, asset: node.asset, + source: node.source, + slots: node.slots, parentId: node.parentId, side: node.side, metadata: { isNew: true }, @@ -105,6 +108,8 @@ export default function ItemPanel() { title={node.name || node.asset.name} width={300} > + + + @@ -174,6 +180,7 @@ export default function ItemPanel() { unit="°" value={Math.round((node.rotation[1] * 180) / Math.PI)} /> +
) } + +/** Tilt about X or Z: aiming a spotlight, leaning a frame. Y stays the main turn above. */ +function TiltSlider({ + axis, + label, + node, + onUpdate, +}: { + axis: 0 | 2 + label: string + node: ItemNode + onUpdate: (updates: Partial) => void +}) { + return ( + + {label} + rot + + } + max={180} + min={-180} + onChange={(degrees) => { + const rotation = [...node.rotation] as [number, number, number] + rotation[axis] = (degrees * Math.PI) / 180 + onUpdate({ rotation }) + }} + precision={0} + step={1} + unit="°" + value={Math.round((node.rotation[axis] * 180) / Math.PI)} + /> + ) +} diff --git a/packages/nodes/src/item/renderer.tsx b/packages/nodes/src/item/renderer.tsx index dad426520b..604217d8a4 100644 --- a/packages/nodes/src/item/renderer.tsx +++ b/packages/nodes/src/item/renderer.tsx @@ -50,7 +50,7 @@ import { useState, } from 'react' import type { AnimationAction, AnimationClip, Group, Material, Mesh, Object3D } from 'three' -import { MathUtils, Texture } from 'three' +import { LoopOnce, MathUtils, Texture } from 'three' import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js' import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js' import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js' @@ -196,7 +196,7 @@ const resolveItemMaterial = ( return authoredMaterial } -const BrokenItemFallback = ({ node }: { node: ItemNode }) => { +const BrokenItemFallback = ({ node, events = true }: { node: ItemNode; events?: boolean }) => { const handlers = useNodeEvents(node, 'item') const shading = useViewer((s) => s.shading) const isExporting = useViewer((s) => s.isExporting) @@ -215,7 +215,7 @@ const BrokenItemFallback = ({ node }: { node: ItemNode }) => { if (isExporting) return null return ( - + @@ -364,10 +364,12 @@ const UnavailableItemModel = ({ markSettled, node, url, + events, }: { markSettled: () => void node: ItemNode url: string + events: boolean }) => { useEffect(() => { retainUnavailableConsumer(unavailableFailureConsumers, node.id) @@ -387,7 +389,7 @@ const UnavailableItemModel = ({ } }, [markSettled, node.id, url]) - return + return } /** @@ -398,9 +400,12 @@ const UnavailableItemModel = ({ const ModelWithRetry = ({ node, setSettled, + events = true, }: { node: ItemNode setSettled: (value: boolean) => void + /** False when a host node (a scripted window or door) owns the pointer events. */ + events?: boolean }) => { const [renderFailed, setRenderFailed] = useState(false) const url = resolveCdnUrl(node.asset.src) || '' @@ -418,16 +423,17 @@ const ModelWithRetry = ({ return () => useViewer.getState().clearItemLoadFailure(node.id) }, [markSettled, node.id, renderFailed, url]) - if (!url) return + if (!url) + return return ( } + fallback={} onError={() => setRenderFailed(true)} scope="item-model" > }> - + ) @@ -579,23 +585,40 @@ const ClearPreviewModel = ({ node }: { node: ItemNode }) => { ) } -const ModelRenderer = ({ node, markSettled }: { node: ItemNode; markSettled: () => void }) => { +const ModelRenderer = ({ + node, + markSettled, + events, +}: { + node: ItemNode + markSettled: () => void + events: boolean +}) => { const gltf = useItemGltf(resolveCdnUrl(node.asset.src) || '') const unavailable = getUnavailableItemAsset(gltf) if (unavailable) { - return + return ( + + ) } - return + return } const LoadedModelRenderer = ({ gltf: { scene, nodes, animations }, node, markSettled, + events, }: { gltf: LoadedItemGltf node: ItemNode markSettled: () => void + events: boolean }) => { const ref = useRef(null!) @@ -644,12 +667,12 @@ const LoadedModelRenderer = ({ if (!(child as Mesh).isMesh) return const mesh = child as Mesh - if (mesh.name === 'cutout') { - child.visible = false - } + // `cutout` drives wall openings, `collider` the walkthrough; neither renders. + const helper = mesh.name === 'cutout' || mesh.name === 'collider' + if (helper) child.visible = false const captured = captureItemMeshMaterials(mesh) - if (mesh.name !== 'cutout') meshEntries.push({ mesh, captured }) + if (!helper) meshEntries.push({ mesh, captured }) }) const materialOptions = { @@ -697,19 +720,25 @@ const LoadedModelRenderer = ({ const lightEffects = interactive?.effects.filter((e): e is LightEffect => e.kind === 'light') ?? [] - // Expose this item's ambient clip (e.g. a fan's spin) to the GLB bake. The - // catalog GLB owns the clip; it isn't in the scene graph, so the export can't - // find it without this registry. The bake retargets it onto the baked subtree. + // Expose this item's clips to the GLB bake: the GLB owns them and they are + // not in the scene graph, so the export can't find them without this + // registry. A catalog item bakes its ambient clip (a fan's spin); an + // authored object bakes every clip, its opening played once. + const scripted = Boolean(node.source) useEffect(() => { - if (!animEffect) return - const clipName = animEffect.clips.on ?? animEffect.clips.loop - const clip = clipName ? animations.find((c) => c.name === clipName) : undefined - if (!clip) return - itemClipRegistry.set(node.id, { clip, loop: true }) + const entries = scripted + ? animations.map((clip) => ({ clip, loop: clip.name !== 'open', name: clip.name })) + : (() => { + const clipName = animEffect ? (animEffect.clips.on ?? animEffect.clips.loop) : undefined + const clip = clipName ? animations.find((c) => c.name === clipName) : undefined + return clip ? [{ clip, loop: true, name: 'loop' }] : [] + })() + if (entries.length === 0) return + itemClipRegistry.set(node.id, entries) return () => { itemClipRegistry.delete(node.id) } - }, [node.id, animEffect, animations]) + }, [node.id, animEffect, animations, scripted]) // useGLTF caches scenes, and Clone shares child geometry/material references. // Undo can unmount one item while another clone of the same asset still needs them. @@ -723,10 +752,18 @@ const LoadedModelRenderer = ({ ref={ref} rotation={node.asset.rotation} scale={node.asset.scale || [1, 1, 1]} - {...handlers} + {...(events ? handlers : {})} /> - {animations.length > 0 && ( + {animations.length > 0 && scripted && interactive && ( + + )} + {animations.length > 0 && !scripted && ( + useInteractive((s) => + control === undefined ? true : Boolean(s.items[nodeId]?.controlValues[control]), + ) + +/** + * An authored object's clips, each driven by its own toggle: an open-close + * effect plays `open` once and holds, closing plays `close` or `open` reversed; + * an ambient effect plays its clip while its toggle is on (always, for `loop`). + */ +const ScriptedAnimations = ({ + nodeId, + interactive, + animations, + rootRef, +}: { + nodeId: AnyNodeId + interactive: Interactive + animations: AnimationClip[] + rootRef: RefObject +}) => { + const { actions } = useAnimations(animations, rootRef) + const effects = interactive.effects.filter( + (effect): effect is AnimationEffect => effect.kind === 'animation', + ) + return ( + <> + {effects.map((effect) => + effect.mode === 'open-close' ? ( + + ) : ( + + ), + )} + + ) +} + +type ClipActions = Record + +const PlayClip = ({ + nodeId, + effect, + actions, +}: { + nodeId: AnyNodeId + effect: AnimationEffect + actions: ClipActions +}) => { + const on = useEffectControl(nodeId, effect.control) + const name = effect.clips.on ?? effect.clips.loop + useEffect(() => { + const action = name ? actions[name] : undefined + if (!action) return + if (on) { + action.paused = false + action.play() + } else { + // Hold the pose where it was, like pausing a music box. + action.paused = true + } + }, [actions, name, on]) + return null +} + +const OpenCloseClip = ({ + nodeId, + effect, + actions, +}: { + nodeId: AnyNodeId + effect: AnimationEffect + actions: ClipActions +}) => { + const isOpen = useEffectControl(nodeId, effect.control) + const mounted = useRef(false) + useEffect(() => { + const open = effect.clips.on ? actions[effect.clips.on] : undefined + const close = effect.clips.off ? actions[effect.clips.off] : undefined + if (!open) return + const first = !mounted.current + mounted.current = true + for (const action of [open, close]) { + if (!action) continue + action.setLoop(LoopOnce, 1) + action.clampWhenFinished = true + } + if (isOpen) { + close?.stop() + open.paused = false + open.timeScale = 1 + if (!open.isRunning()) open.reset() + open.play() + // Already open when the scene loads: hold the open pose, no swing. + if (first) open.time = open.getClip().duration + return + } + if (first) return + if (close) { + open.stop() + close.reset().play() + return + } + open.paused = false + open.timeScale = -1 + open.play() + }, [actions, effect.clips.on, effect.clips.off, isOpen]) + return null +} + const ItemLightRegistrar = ({ nodeId, effect, @@ -845,4 +998,18 @@ const ItemLightRegistrar = ({ return null } +/** + * An artifact rendered through the item's model path (GLB, paint slots, + * clips, lights, load settling) for a node that is not an item: a window or + * door built from a script. `view` is that node seen as an item; the host + * node keeps its own registry entry and pointer events. + */ +export const ScriptedModel = ({ + view, + setSettled, +}: { + view: ItemNode + setSettled: (value: boolean) => void +}) => + export default ItemRenderer diff --git a/packages/nodes/src/shared/node-batch/batchable.ts b/packages/nodes/src/shared/node-batch/batchable.ts index 1704fee72f..ba75fdf1c9 100644 --- a/packages/nodes/src/shared/node-batch/batchable.ts +++ b/packages/nodes/src/shared/node-batch/batchable.ts @@ -19,11 +19,16 @@ export const surfaceBatchable: BatchableConfig = { export const itemBatchable: BatchableConfig = { scope: 'level', - // A registered clip means the item animates its own subtree (a fan's - // spin) — per-mesh transforms move under a static batch instance. + // An animation effect or a registered clip means the item animates its own + // subtree (a fan's spin) — per-mesh transforms move under a static batch + // instance. Light effects drive separate light objects, so a lit porch or + // lamp still batches. excluded: (node) => - Boolean((node as { asset?: { interactive?: unknown } }).asset?.interactive) || - itemClipRegistry.has(node.id as string), + Boolean( + ( + node as { asset?: { interactive?: { effects?: { kind: string }[] } } } + ).asset?.interactive?.effects?.some((effect) => effect.kind === 'animation'), + ) || itemClipRegistry.has(node.id as string), // Items hold their dirty mark until the GLB settles. A GLB that ships clips // autoplays its first one even without an interactive effect // (ItemAnimation's no-effect fallback) — static batching would freeze it. diff --git a/packages/nodes/src/shared/node-batch/node-batch.test.ts b/packages/nodes/src/shared/node-batch/node-batch.test.ts index 3c001b64e6..9b1a411df6 100644 --- a/packages/nodes/src/shared/node-batch/node-batch.test.ts +++ b/packages/nodes/src/shared/node-batch/node-batch.test.ts @@ -354,7 +354,7 @@ test('items retain loading, animation, transparency, hidden-hitbox and dirty-rej expect(meshes[0]!.layers.isEnabled(SCENE_LAYER)).toBe(false) useViewer.setState({ hoveredId: 'item_0' } as never) frame() - itemClipRegistry.set('item_0', {} as never) + itemClipRegistry.set('item_0', [] as never) expect(collectBatchCandidate('item_0')).toBeNull() itemClipRegistry.delete('item_0') }) diff --git a/packages/nodes/src/shared/opening-radius-handles.test.ts b/packages/nodes/src/shared/opening-radius-handles.test.ts index cd54b2c3b4..56e3696929 100644 --- a/packages/nodes/src/shared/opening-radius-handles.test.ts +++ b/packages/nodes/src/shared/opening-radius-handles.test.ts @@ -20,10 +20,14 @@ function radiusHandles( ) } +function handlesOf(handles: unknown, node: N): HandleDescriptor[] { + return typeof handles === 'function' ? handles(node, scene) : (handles as HandleDescriptor[]) +} + describe('opening corner-radius handles', () => { test('window exposes four dots and supports shared or Shift-isolated rounding', () => { const window = WindowNode.parse({ id: 'window_radius', cornerRadius: 0.15 }) - const handles = radiusHandles(windowDefinition.handles) + const handles = radiusHandles(handlesOf(windowDefinition.handles, window)) expect(handles).toHaveLength(4) expect(handles[0]?.apply(window, 0.3, scene, { altKey: false })).toMatchObject({ @@ -42,7 +46,7 @@ describe('opening corner-radius handles', () => { test('door exposes only its top two dots and hides them for an arch', () => { const door = DoorNode.parse({ id: 'door_radius', cornerRadius: 0.15 }) - const handles = radiusHandles(doorDefinition.handles) + const handles = radiusHandles(handlesOf(doorDefinition.handles, door)) expect(handles.map((handle) => handle.corner)).toEqual([ [-1, 1], diff --git a/packages/nodes/src/shared/scripted-opening-handles.test.ts b/packages/nodes/src/shared/scripted-opening-handles.test.ts new file mode 100644 index 0000000000..34e7e7c280 --- /dev/null +++ b/packages/nodes/src/shared/scripted-opening-handles.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from 'bun:test' +import { + createSceneApi, + DoorNode, + type HandleDescriptor, + useScene, + WindowNode, +} from '@pascal-app/core' +import { doorDefinition } from '../door/definition' +import { windowDefinition } from '../window/definition' + +const scene = createSceneApi(useScene) +const sha = 'a'.repeat(64) + +function source(params: { id: string; default: number }[]) { + return { + kind: 'script' as const, + language: 'three' as const, + script: sha, + artifact: sha, + params: {}, + manifest: { + bounds: { min: [-0.9, 0, -0.1], max: [0.9, 2.4, 0.1] }, + params: params.map((param) => ({ ...param, kind: 'number', label: param.id })), + parts: [], + surfaces: [], + undersides: [], + slots: [], + anchors: [], + lights: [], + animations: [], + cutout: true, + collider: false, + triangles: 12, + }, + } +} + +const kinds = (handles: HandleDescriptor[]) => handles.map((handle) => handle.kind) + +describe('a scripted opening shows only the handles its params drive', () => { + test('no params: no resize arrows and no rounding dots', () => { + const window = WindowNode.parse({ id: 'window_s', source: source([]) }) + const handles = (windowDefinition.handles as Function)(window, scene) as HandleDescriptor[] + expect(handles).toEqual([]) + }) + + test('a width param keeps the two side arrows, nothing else', () => { + const window = WindowNode.parse({ + id: 'window_w', + source: source([{ id: 'width', default: 1.8 }]), + }) + const handles = (windowDefinition.handles as Function)(window, scene) as HandleDescriptor[] + expect(kinds(handles)).toEqual(['linear-resize', 'linear-resize']) + }) + + test('a door keeps its move handle and drops the rounding dots', () => { + const door = DoorNode.parse({ id: 'door_s', source: source([{ id: 'height', default: 2.4 }]) }) + const handles = (doorDefinition.handles as Function)(door, scene) as HandleDescriptor[] + expect(handles.some((handle) => handle.kind === 'corner-radius')).toBe(false) + expect(handles.filter((handle) => handle.kind === 'linear-resize')).toHaveLength(1) + }) +}) diff --git a/packages/nodes/src/shared/scripted-opening-handles.ts b/packages/nodes/src/shared/scripted-opening-handles.ts new file mode 100644 index 0000000000..df4309c5a6 --- /dev/null +++ b/packages/nodes/src/shared/scripted-opening-handles.ts @@ -0,0 +1,51 @@ +import type { DoorNode, HandleDescriptor, LinearResizeHandle, WindowNode } from '@pascal-app/core' +import { rebuildAuthoredObject } from '@pascal-app/editor' + +type Opening = WindowNode | DoorNode + +/** + * A window or door built from a script resizes only through the params its + * script declares: a `width` param keeps the side arrows, a `height` param + * the top arrow (the bottom stays on its sill or threshold), and nothing + * else shows. The drag previews the box; release rebuilds the script with + * the param moved by the dragged amount (trim around the opening included). + */ +export function scriptedOpeningHandles( + node: N, + width: (side: 'left' | 'right') => HandleDescriptor, + height: () => HandleDescriptor, +): HandleDescriptor[] { + const declared = new Set(node.source?.manifest.params.map((spec) => spec.id)) + const handles: HandleDescriptor[] = [] + if (declared.has('width')) { + handles.push(byParam(width('left'), 'width'), byParam(width('right'), 'width')) + } + if (declared.has('height')) handles.push(byParam(height(), 'height')) + return handles +} + +function byParam( + handle: HandleDescriptor, + param: 'width' | 'height', +): HandleDescriptor { + const linear = handle as LinearResizeHandle + return { + ...linear, + commit: (initial, patch) => { + const source = initial.source + const next = patch[param] + if (!source || typeof next !== 'number') return + const spec = source.manifest.params.find((candidate) => candidate.id === param) + const current = Number(source.params[param] ?? spec?.default ?? initial[param]) + const value = clamp(current + (next - initial[param]), spec?.min, spec?.max) + // A side arrow keeps the opposite edge: the rebuild takes the moved centre. + const position = param === 'width' ? (patch.position as N['position'] | undefined) : undefined + rebuildAuthoredObject(initial.id, { ...source.params, [param]: value }, position).catch( + (reason: unknown) => console.error('[scripted opening] resize failed', reason), + ) + }, + } +} + +const clamp = (value: number, min?: number, max?: number) => + Math.min(max ?? Number.POSITIVE_INFINITY, Math.max(min ?? Number.NEGATIVE_INFINITY, value)) diff --git a/packages/nodes/src/shared/scripted-opening.tsx b/packages/nodes/src/shared/scripted-opening.tsx new file mode 100644 index 0000000000..7b684abce9 --- /dev/null +++ b/packages/nodes/src/shared/scripted-opening.tsx @@ -0,0 +1,85 @@ +'use client' + +import { + type AnyNodeId, + artifactUrl, + type DoorNode, + type ItemNode, + scriptedSize, + scriptInteractive, + useInteractive, + useScene, + type WindowNode, +} from '@pascal-app/core' +import { SCRIPTED_MODEL_FLAG } from '@pascal-app/viewer' +import { useCallback, useMemo, useRef } from 'react' +import type { Group } from 'three' +import { ScriptedModel } from '../item/renderer' + +/** + * A window or door built from a script, rendered exactly as an authored item: + * the same artifact, paint slots, clips and lights. The opening's mesh is + * centred on the opening; the artifact's origin is its bottom centre. + */ +export function ScriptedOpeningModel({ node }: { node: WindowNode | DoorNode }) { + const source = node.source! + const ref = useRef(null) + const view = useMemo(() => { + const [width, height, depth] = scriptedSize(source.manifest) + return { + id: node.id, + type: 'item', + parentId: node.parentId, + metadata: node.metadata, + slots: node.slots, + source, + scale: [1, 1, 1], + asset: { + src: artifactUrl(source.artifact), + dimensions: [width, height, depth], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + interactive: scriptInteractive(source.manifest), + }, + } as unknown as ItemNode + }, [node.id, node.parentId, node.metadata, node.slots, source]) + const setSettled = useCallback( + (settled: boolean) => { + if (ref.current) ref.current.userData.itemModelSettled = settled + // The opening's system holds its dirty mark until the artifact has loaded. + if (settled) useScene.getState().markDirty(node.id) + }, + [node.id], + ) + const [, height] = scriptedSize(source.manifest) + return ( + + + + ) +} + +/** The toggle that plays a scripted opening's `open` clip, as the item's Open control. */ +function openControl(node: { source?: WindowNode['source'] }): number | undefined { + if (!node.source) return undefined + const effect = scriptInteractive(node.source.manifest)?.effects.find( + (candidate) => candidate.kind === 'animation' && candidate.mode === 'open-close', + ) + return effect?.kind === 'animation' ? effect.control : undefined +} + +/** Open and Close for a window or door built from a script: its `open` clip, through the item toggles. */ +export const scriptedOpening = { + has: (node: { source?: WindowNode['source'] }) => openControl(node) !== undefined, + isOn: (node: { id: string; source?: WindowNode['source'] }) => { + const control = openControl(node) + if (control === undefined) return false + return Boolean(useInteractive.getState().items[node.id as AnyNodeId]?.controlValues[control]) + }, + set: (node: { id: string; source?: WindowNode['source'] }, on: boolean) => { + const control = openControl(node) + if (control !== undefined) + useInteractive.getState().setControlValue(node.id as AnyNodeId, control, on) + }, +} diff --git a/packages/nodes/src/window/definition.test.ts b/packages/nodes/src/window/definition.test.ts index ead72bac43..8a4aa291ef 100644 --- a/packages/nodes/src/window/definition.test.ts +++ b/packages/nodes/src/window/definition.test.ts @@ -13,7 +13,9 @@ import { } from '@pascal-app/core' import { resolveWindowHandlePortalTarget, windowDefinition } from './definition' -const windowHandles = windowDefinition.handles as HandleDescriptor[] +const windowHandles = ( + windowDefinition.handles as (node: WindowNode) => HandleDescriptor[] +)(WindowNode.parse({ id: 'window_handles' })) function sceneWith(...nodes: AnyNode[]): SceneApi { const byId = Object.fromEntries(nodes.map((node) => [node.id, node])) as Record< @@ -40,7 +42,7 @@ function resizeToMax(index: number, window: WindowNode, scene: SceneApi): Partia describe('window handle presentation', () => { test('does not register the legacy move arrow', () => { - const handles = windowDefinition.handles as HandleDescriptor[] + const handles = windowHandles as HandleDescriptor[] expect(handles.some((handle) => 'shape' in handle && handle.shape === 'move-cross')).toBe(false) }) diff --git a/packages/nodes/src/window/definition.ts b/packages/nodes/src/window/definition.ts index e900e6a616..8d5e7a54bc 100644 --- a/packages/nodes/src/window/definition.ts +++ b/packages/nodes/src/window/definition.ts @@ -24,6 +24,7 @@ import { createOpeningPropertyPreview } from '../shared/opening-property-preview import { openingPropertyPreviewHost } from '../shared/opening-property-preview-host' import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host' import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut' +import { scriptedOpeningHandles } from '../shared/scripted-opening-handles' import { readHostWallCeiling } from '../shared/wall-opening-ceiling' import { wallFloorplanSiblingOverrides } from '../wall/floorplan-overrides' import { buildWindowContextualDimensions } from './contextual-dimensions' @@ -314,7 +315,10 @@ export const windowDefinition: NodeDefinition = { }, parametrics: windowParametrics, - handles: windowHandles, + handles: (node) => + node.source + ? scriptedOpeningHandles(node, windowWidthHandle, () => windowHeightHandle('top')) + : windowHandles, rendersChildren: false, renderer: { diff --git a/packages/nodes/src/window/floorplan.ts b/packages/nodes/src/window/floorplan.ts index d714981f3d..ace14cf07d 100644 --- a/packages/nodes/src/window/floorplan.ts +++ b/packages/nodes/src/window/floorplan.ts @@ -230,24 +230,27 @@ export function buildWindowFloorplan( // `resize-width` affordance — anchored at the opposite edge, clamped // to wall bounds. Mirrors the 3D `WindowSideArrow` width drag and the // door's 2D pattern. - const startEdgeX = cx - dirX * halfWidth - const startEdgeZ = cz - dirZ * halfWidth - const endEdgeX = cx + dirX * halfWidth - const endEdgeZ = cz + dirZ * halfWidth - children.push({ - kind: 'move-arrow', - point: [startEdgeX, startEdgeZ], - angle: Math.atan2(-dirZ, -dirX), - affordance: 'resize-width', - payload: { side: 'start' }, - }) - children.push({ - kind: 'move-arrow', - point: [endEdgeX, endEdgeZ], - angle: Math.atan2(dirZ, dirX), - affordance: 'resize-width', - payload: { side: 'end' }, - }) + // A scripted opening's width is its script's: its size arrows live in 3D, on its params. + if (!node.source) { + const startEdgeX = cx - dirX * halfWidth + const startEdgeZ = cz - dirZ * halfWidth + const endEdgeX = cx + dirX * halfWidth + const endEdgeZ = cz + dirZ * halfWidth + children.push({ + kind: 'move-arrow', + point: [startEdgeX, startEdgeZ], + angle: Math.atan2(-dirZ, -dirX), + affordance: 'resize-width', + payload: { side: 'start' }, + }) + children.push({ + kind: 'move-arrow', + point: [endEdgeX, endEdgeZ], + angle: Math.atan2(dirZ, dirX), + affordance: 'resize-width', + payload: { side: 'end' }, + }) + } } // Placement-measurement dimensions when actively moving — same diff --git a/packages/nodes/src/window/mechanism.ts b/packages/nodes/src/window/mechanism.ts index 0532127b5f..3fea602f84 100644 --- a/packages/nodes/src/window/mechanism.ts +++ b/packages/nodes/src/window/mechanism.ts @@ -5,6 +5,7 @@ import { isOperableWindowType, openWindowOpenState, } from '@pascal-app/editor' +import { scriptedOpening } from '../shared/scripted-opening' /** A window's sash: Open and Close preview it without touching the saved open state. */ export const windowMechanism: MechanismCapability = { @@ -12,9 +13,16 @@ export const windowMechanism: MechanismCapability = { icon: 'window', has: (node) => node.type === 'window' && - node.openingKind !== 'opening' && - isOperableWindowType(node.windowType), + (node.source + ? scriptedOpening.has(node) + : node.openingKind !== 'opening' && isOperableWindowType(node.windowType)), isOn: (node) => - node.type === 'window' && getDisplayedWindowValue(node.id, node.operationState) > 0, - set: (node, on) => (on ? openWindowOpenState : closeWindowOpenState)(node.id, { persist: false }), + node.type === 'window' && + (node.source + ? scriptedOpening.isOn(node) + : getDisplayedWindowValue(node.id, node.operationState) > 0), + set: (node, on) => { + if (node.type === 'window' && node.source) return scriptedOpening.set(node, on) + return (on ? openWindowOpenState : closeWindowOpenState)(node.id, { persist: false }) + }, } diff --git a/packages/nodes/src/window/panel.tsx b/packages/nodes/src/window/panel.tsx index e54c36fc0e..9b0c4c51de 100644 --- a/packages/nodes/src/window/panel.tsx +++ b/packages/nodes/src/window/panel.tsx @@ -22,6 +22,7 @@ import { import { useViewer } from '@pascal-app/viewer' import { Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react' import { useCallback, useEffect, useMemo } from 'react' +import { AuthoredParams } from '../item/authored-params' import { constrainCurtainOpening, curtainOpeningLimits } from '../shared/curtain-opening-limits' import { createOpeningPropertyPreview } from '../shared/opening-property-preview' import { openingPropertyPreviewHost } from '../shared/opening-property-preview-host' @@ -219,6 +220,8 @@ export default function WindowPanel() { const normCols = node.columnRatios.map((r) => r / colSum) const normRows = node.rowRatios.map((r) => r / rowSum) const isOpening = node.openingKind === 'opening' + // Built from a script: its params replace the parametric frame's fields, as on an authored item. + const scripted = Boolean(node.source) const openingShape = node.openingShape ?? 'rectangle' const windowShape = openingShape === 'arch' || openingShape === 'rounded' ? openingShape : 'rectangle' @@ -243,15 +246,15 @@ export default function WindowPanel() { const supportsWindowShape = shapedWindowTypes.has(node.windowType ?? 'fixed') const supportsGrid = isFixedWindow const supportsSill = !silllessWindowTypes.has(node.windowType) - const showWindowTypeSection = !isOpening - const showWindowShapeSection = !isOpening && supportsWindowShape - const showOpeningShapeSection = isOpening - const showFrameSection = !isOpening - const showGridSection = !isOpening && supportsGrid - const showSillSection = !isOpening && supportsSill - const showOperationSection = !isOpening && isOperableWindow - const showAwningDirectionSection = !isOpening && displayedWindowType === 'awning' - const showCasementSection = !isOpening && windowType === 'casement' + const showWindowTypeSection = !scripted && !isOpening + const showWindowShapeSection = !scripted && !isOpening && supportsWindowShape + const showOpeningShapeSection = !scripted && isOpening + const showFrameSection = !scripted && !isOpening + const showGridSection = !scripted && !isOpening && supportsGrid + const showSillSection = !scripted && !isOpening && supportsSill + const showOperationSection = !scripted && !isOpening && isOperableWindow + const showAwningDirectionSection = !scripted && !isOpening && displayedWindowType === 'awning' + const showCasementSection = !scripted && !isOpening && windowType === 'casement' const showFlipSide = !isOpening const operationLabel = isTrackSashWindow ? windowType === 'sliding' @@ -342,30 +345,32 @@ export default function WindowPanel() { title={node.name || 'Window'} width={320} > - - - handleUpdate({ - openingKind: value as WindowNode['openingKind'], - ...(value === 'opening' - ? { - openingShape, - openingRadiusMode, - openingCornerRadii, - cornerRadius, - archHeight, - openingRevealRadius, - } - : {}), - }) - } - options={[ - { value: 'window', label: 'Window' }, - { value: 'opening', label: 'Opening' }, - ]} - value={node.openingKind ?? 'window'} - /> - + {!scripted && ( + + + handleUpdate({ + openingKind: value as WindowNode['openingKind'], + ...(value === 'opening' + ? { + openingShape, + openingRadiusMode, + openingCornerRadii, + cornerRadius, + archHeight, + openingRevealRadius, + } + : {}), + }) + } + options={[ + { value: 'window', label: 'Window' }, + { value: 'opening', label: 'Opening' }, + ]} + value={node.openingKind ?? 'window'} + /> + + )} {showWindowTypeSection && ( @@ -495,41 +500,45 @@ export default function WindowPanel() { )} - - {limits && ( -

- Size is limited to the wall, including clearance for the opening frame. -

- )} - preview?.preview(getDimensionUpdates({ width: v }))} - onCommit={(v) => preview?.commit(getDimensionUpdates({ width: v }))} - onCancel={() => preview?.cancel()} - previewWhileTyping - precision={2} - restoreOnCommit={false} - step={0.01} - unit="m" - value={node.width} - /> - preview?.preview(getDimensionUpdates({ height: v }))} - onCommit={(v) => preview?.commit(getDimensionUpdates({ height: v }))} - onCancel={() => preview?.cancel()} - previewWhileTyping - precision={2} - restoreOnCommit={false} - step={0.01} - unit="m" - value={node.height} - /> -
+ {scripted && } + + {!scripted && ( + + {limits && ( +

+ Size is limited to the wall, including clearance for the opening frame. +

+ )} + preview?.preview(getDimensionUpdates({ width: v }))} + onCommit={(v) => preview?.commit(getDimensionUpdates({ width: v }))} + onCancel={() => preview?.cancel()} + previewWhileTyping + precision={2} + restoreOnCommit={false} + step={0.01} + unit="m" + value={node.width} + /> + preview?.preview(getDimensionUpdates({ height: v }))} + onCommit={(v) => preview?.commit(getDimensionUpdates({ height: v }))} + onCancel={() => preview?.cancel()} + previewWhileTyping + precision={2} + restoreOnCommit={false} + step={0.01} + unit="m" + value={node.height} + /> +
+ )} {showWindowShapeSection && ( @@ -749,7 +758,7 @@ export default function WindowPanel() { )} - {!isOpening && ( + {!isOpening && !scripted && ( <> {showFrameSection && ( diff --git a/packages/nodes/src/window/renderer.tsx b/packages/nodes/src/window/renderer.tsx index 2073bfd72d..6d3d4fcd17 100644 --- a/packages/nodes/src/window/renderer.tsx +++ b/packages/nodes/src/window/renderer.tsx @@ -10,6 +10,7 @@ import { import { useLayoutEffect, useMemo, useRef } from 'react' import type { Mesh } from 'three' import { RoofFaceHostFrame } from '../shared/roof-face-host' +import { ScriptedOpeningModel } from '../shared/scripted-opening' export const WindowRenderer = ({ node }: { node: WindowNode }) => { const ref = useRef(null!) @@ -46,6 +47,7 @@ export const WindowRenderer = ({ node }: { node: WindowNode }) => { {...(isTransient ? {} : handlers)} > + {renderNode.source && } ) diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 8300a978d5..954a80a9e8 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -196,6 +196,7 @@ export { showInScene, temporarilyShowShadowOnly, } from './lib/scene-visibility' +export { SCRIPTED_MODEL_FLAG } from './lib/scripted-opening' export { createPlainSnapshotPipeline, createSnapshotPipeline, diff --git a/packages/viewer/src/lib/asset-url.ts b/packages/viewer/src/lib/asset-url.ts index 6ec7d3178c..33c180b788 100644 --- a/packages/viewer/src/lib/asset-url.ts +++ b/packages/viewer/src/lib/asset-url.ts @@ -1,4 +1,4 @@ -import { loadAssetUrl } from '@pascal-app/core' +import { loadAssetUrl, resolveArtifactUrl } from '@pascal-app/core' export const ASSETS_CDN_URL = process.env.NEXT_PUBLIC_ASSETS_CDN_URL || 'https://editor.pascal.app' @@ -12,6 +12,8 @@ export const ASSETS_CDN_URL = process.env.NEXT_PUBLIC_ASSETS_CDN_URL || 'https:/ export async function resolveAssetUrl(url: string | undefined | null): Promise { if (!url) return null + if (url.startsWith('artifact://')) return resolveArtifactUrl(url) + // External URL - use as-is if (url.startsWith('http://') || url.startsWith('https://')) { return url @@ -34,6 +36,9 @@ export async function resolveAssetUrl(url: string | undefined | null): Promise (object as THREE.Mesh).geometry?.dispose()) + } + return settled +} diff --git a/packages/viewer/src/systems/door/door-system.tsx b/packages/viewer/src/systems/door/door-system.tsx index e018f1f3c5..89d72726c1 100644 --- a/packages/viewer/src/systems/door/door-system.tsx +++ b/packages/viewer/src/systems/door/door-system.tsx @@ -11,6 +11,7 @@ import { type SceneMaterial, type SceneMaterialId, sceneRegistry, + scriptedSize, useInteractive, useLiveNodeOverrides, useScene, @@ -30,6 +31,7 @@ import { resolveMaterialRef, } from '../../lib/materials' import { timeSpan } from '../../lib/perf-tracks' +import { settleScriptedOpening } from '../../lib/scripted-opening' import useViewer from '../../store/use-viewer' import { getOpeningCutoutProxyDepth } from '../wall/opening-cutout-geometry' @@ -178,9 +180,11 @@ export const DoorSystem = () => { // rebuild reflects the in-flight drag without zustand churn. When // no override is set this returns the scene node unchanged. const effectiveNode = getEffectiveNode(node as DoorNode) - timeSpan('door', () => updateDoorMesh(effectiveNode, mesh), { + const built = timeSpan('door', () => updateDoorMesh(effectiveNode, mesh), { properties: [['node', id]], }) + // A scripted opening stays dirty until its artifact has loaded. + if (!built) continue clearDirty(id as AnyNodeId) rebuiltDoorsThisFrame += 1 @@ -2301,7 +2305,7 @@ function getEffectiveOpeningShape(node: DoorNode): DoorNode['openingShape'] { : (node.openingShape ?? 'rectangle') } -function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { +function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh): boolean { const node = normalizeDoorNodeForRender(rawNode) currentDoorSlot = undefined @@ -2319,6 +2323,14 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { mesh.position.set(...placement.position) mesh.rotation.set(...placement.rotation) + // Built from a script: the renderer shows its artifact, not the parametric + // frame, and the hit box is what the script built. + if (node.source) { + mesh.geometry.dispose() + mesh.geometry = new THREE.BoxGeometry(...scriptedSize(node.source.manifest)) + return settleScriptedOpening(mesh) + } + // Dispose and remove all old visual children; preserve 'cutout' for (const child of [...mesh.children]) { if (child.name === 'cutout') continue @@ -2368,7 +2380,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { if (openingKind === 'opening') { syncDoorCutout(node, mesh) - return + return true } const insideWidth = width - 2 * frameThickness @@ -2687,6 +2699,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { // … was not set" on a Draw(0, …)). Hide any empty mesh so it is never // drawn (it would render nothing anyway). hideEmptyGeometryMeshes(mesh) + return true } function hideEmptyGeometryMeshes(root: THREE.Object3D) { diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index 096873be71..4c2f18a634 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -1880,6 +1880,46 @@ function withWallFinishData( * cuts come directly from node geometry; item proxy meshes are transformed * into wall-local boxes that pass through the wall. */ +/** + * A CSG brush from an authored object's `cutout` mesh: the mesh in wall-local + * space, its depth stretched to twice the wall's thickness about the wall's + * body centre so it overshoots both faces. Null when the mesh has no depth to + * stretch (a flat cutter), so the caller falls back to its bounding box. + */ +function authoredCutoutBrush( + cutoutMesh: THREE.Mesh, + wallMatrixInverse: THREE.Matrix4, + wallThickness: number, + wallNode: WallNode, +): Brush | null { + const geometry = cutoutMesh.geometry.clone() + geometry.applyMatrix4( + new THREE.Matrix4().multiplyMatrices(wallMatrixInverse, cutoutMesh.matrixWorld), + ) + geometry.computeBoundingBox() + const box = geometry.boundingBox! + const depth = box.max.z - box.min.z + if (!(depth > 1e-4)) { + geometry.dispose() + return null + } + const centre = getWallBodyCenterOffset(wallNode) + const meshCentre = (box.min.z + box.max.z) / 2 + const scale = (wallThickness * 2) / depth + const positions = geometry.getAttribute('position') + for (let i = 0; i < positions.count; i++) { + positions.setZ(i, centre + (positions.getZ(i) - meshCentre) * scale) + } + positions.needsUpdate = true + // The evaluator needs the attributes every brush carries. + if (!geometry.getAttribute('uv')) { + geometry.setAttribute('uv', new THREE.BufferAttribute(new Float32Array(positions.count * 2), 2)) + } + geometry.computeVertexNormals() + computeGeometryBoundsTree(geometry) + return new Brush(geometry) +} + function collectCutoutBrushes( wallNode: WallNode, childrenNodes: AnyNode[], @@ -1900,7 +1940,13 @@ function collectCutoutBrushes( for (const child of childrenNodes) { if (child.type !== 'item' && child.type !== 'window' && child.type !== 'door') continue - if (child.type === 'door' || child.type === 'window') { + // A window or door built from a script cuts its `cutout` mesh like an + // authored item (below); without one, or until it loads, its outline. + const scriptedCutout = + child.type !== 'item' && + child.source && + sceneRegistry.nodes.get(child.id)?.getObjectByName('cutout') + if ((child.type === 'door' || child.type === 'window') && !scriptedCutout) { const nodes = { ...sceneNodes, [wallNode.id]: wallNode, @@ -2034,6 +2080,16 @@ function collectCutoutBrushes( if (!Number.isFinite(minX)) continue + // An authored object's cutout keeps its shape (an arch, a circle): its own + // geometry in wall space, stretched across the wall so it cuts both faces. + if (child.source) { + const shaped = authoredCutoutBrush(cutoutMesh, wallMatrixInverse, wallThickness, wallNode) + if (shaped) { + brushes.push(shaped) + continue + } + } + // Create a box geometry that extends through the wall thickness const width = maxX - minX const height = maxY - minY diff --git a/packages/viewer/src/systems/window/window-system.tsx b/packages/viewer/src/systems/window/window-system.tsx index 610c05a7b4..556df11666 100644 --- a/packages/viewer/src/systems/window/window-system.tsx +++ b/packages/viewer/src/systems/window/window-system.tsx @@ -7,6 +7,7 @@ import { type SceneMaterial, type SceneMaterialId, sceneRegistry, + scriptedSize, useInteractive, useLiveNodeOverrides, useScene, @@ -26,6 +27,7 @@ import { resolveMaterialRef, } from '../../lib/materials' import { timeSpan } from '../../lib/perf-tracks' +import { settleScriptedOpening } from '../../lib/scripted-opening' import useViewer from '../../store/use-viewer' import { getOpeningCutoutProxyDepth } from '../wall/opening-cutout-geometry' @@ -159,9 +161,11 @@ export const WindowSystem = () => { // Merge any live override (width / height / position) so the mesh // rebuild reflects the in-flight drag without zustand churn. const effectiveNode = getEffectiveNode(node as WindowNode) - timeSpan('window', () => updateWindowMesh(effectiveNode, mesh), { + const built = timeSpan('window', () => updateWindowMesh(effectiveNode, mesh), { properties: [['node', id]], }) + // A scripted opening stays dirty until its artifact has loaded. + if (!built) continue clearDirty(id as AnyNodeId) rebuiltWindowsThisFrame += 1 @@ -3371,7 +3375,7 @@ function addShapedLouveredWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { } } -function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { +function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh): boolean { currentWindowSlot = undefined // Root mesh is an invisible hitbox; all visuals live in child meshes @@ -3388,6 +3392,14 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { mesh.position.set(...placement.position) mesh.rotation.set(...placement.rotation) + // Built from a script: the renderer shows its artifact, not the parametric + // frame, and the hit box is what the script built. + if (node.source) { + mesh.geometry.dispose() + mesh.geometry = new THREE.BoxGeometry(...scriptedSize(node.source.manifest)) + return settleScriptedOpening(mesh) + } + // Dispose and remove all old visual children; preserve 'cutout' for (const child of [...mesh.children]) { if (child.name === 'cutout') continue @@ -3420,73 +3432,73 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { if (openingKind === 'opening') { syncWindowCutout(node, mesh) - return + return true } if (windowType === 'sliding') { addSlidingWindowVisuals(node, mesh) syncWindowCutout(node, mesh) - return + return true } if (windowType === 'casement') { addCasementWindowVisuals(node, mesh) syncWindowCutout(node, mesh) - return + return true } if (windowType === 'awning') { addAwningWindowVisuals(node, mesh) syncWindowCutout(node, mesh) - return + return true } if (windowType === 'hopper') { addAwningWindowVisuals(node, mesh) syncWindowCutout(node, mesh) - return + return true } if (windowType === 'single-hung') { addSingleHungWindowVisuals(node, mesh) syncWindowCutout(node, mesh) - return + return true } if (windowType === 'double-hung') { addDoubleHungWindowVisuals(node, mesh) syncWindowCutout(node, mesh) - return + return true } if (windowType === 'bay') { addBayWindowVisuals(node, mesh) syncWindowCutout(node, mesh) - return + return true } if (windowType === 'bow') { addBowWindowVisuals(node, mesh) syncWindowCutout(node, mesh) - return + return true } if (windowType === 'louvered') { addLouveredWindowVisuals(node, mesh) syncWindowCutout(node, mesh) - return + return true } if (openingShape === 'arch') { addArchedWindowVisuals(node, mesh) syncWindowCutout(node, mesh) - return + return true } if (openingShape === 'rounded') { addRoundedWindowVisuals(node, mesh) syncWindowCutout(node, mesh) - return + return true } const innerW = width - 2 * frameThickness @@ -3643,6 +3655,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { } syncWindowCutout(node, mesh) + return true } function syncWindowCutout(node: WindowNode, mesh: THREE.Mesh) { diff --git a/plugin-evals/tool-annotation-justifications.json b/plugin-evals/tool-annotation-justifications.json index d0faf79ce4..a7edb408a9 100644 --- a/plugin-evals/tool-annotation-justifications.json +++ b/plugin-evals/tool-annotation-justifications.json @@ -19,6 +19,19 @@ "openWorldHint": "It operates on the connected Pascal scene, project, and configured catalog data without accessing the public internet." } }, + { + "name": "add_object", + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "openWorldHint": false + }, + "justifications": { + "readOnlyHint": "It is not read-only because it creates an object from a three.js module in the connected Pascal scene, or rebuilds an existing one.", + "destructiveHint": "Given an existing object's nodeId it replaces that object's geometry and script, so earlier authored state is overwritten (undo restores it).", + "openWorldHint": "It runs the supplied module in the Pascal server's script host and stores the result with the scene, without accessing the public internet." + } + }, { "name": "add_window", "annotations": { @@ -370,6 +383,19 @@ "openWorldHint": "It operates only on data supplied by or already available inside the connected Pascal service and does not access the public internet." } }, + { + "name": "find_by_type", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "openWorldHint": false + }, + "justifications": { + "readOnlyHint": "It only reads the connected Pascal scene: nodes of a type and the typed parts recorded in authored objects.", + "destructiveHint": "It changes nothing; it returns ids, names, part ids and bounds.", + "openWorldHint": "It reads the connected Pascal scene without accessing the public internet." + } + }, { "name": "find_nodes", "annotations": { @@ -461,6 +487,19 @@ "openWorldHint": "It operates only on data supplied by or already available inside the connected Pascal service and does not access the public internet." } }, + { + "name": "get_source", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "openWorldHint": false + }, + "justifications": { + "readOnlyHint": "It only reads the script stored for an object built with add_object, with its params.", + "destructiveHint": "It changes nothing; it returns the module text and param values.", + "openWorldHint": "It reads from the connected Pascal service's artifact store without accessing the public internet." + } + }, { "name": "get_walls", "annotations": { diff --git a/scripts/openai-tool-annotation-policy.test.ts b/scripts/openai-tool-annotation-policy.test.ts index 9ad4158519..132f0e45d1 100644 --- a/scripts/openai-tool-annotation-policy.test.ts +++ b/scripts/openai-tool-annotation-policy.test.ts @@ -13,7 +13,7 @@ describe('OpenAI tool annotation justification packet', () => { const candidate = clonePacket() candidate.tools.pop() expect(validateOpenAiToolAnnotationPacket(candidate)).toContain( - 'OpenAI tool annotation packet must contain the exact 65-tool inventory', + 'OpenAI tool annotation packet must contain the exact 68-tool inventory', ) }) diff --git a/scripts/openai-tool-annotation-policy.ts b/scripts/openai-tool-annotation-policy.ts index 3ec969f161..9bf524b6c6 100644 --- a/scripts/openai-tool-annotation-policy.ts +++ b/scripts/openai-tool-annotation-policy.ts @@ -19,6 +19,9 @@ export const EXPECTED_OPENAI_TOOL_ANNOTATIONS = { analyze_floorplan_image: policy(true, false, true), analyze_room_photo: policy(true, false, true), apply_patch: policy(false, true, false), + find_by_type: policy(true, false, false), + get_source: policy(true, false, false), + add_object: policy(false, true, false), check_collisions: policy(true, false, false), create_from_template: policy(false, true, false), create_house_from_brief: policy(false, true, false), diff --git a/skills/pascal-3d/SKILL.md b/skills/pascal-3d/SKILL.md index 96b0435e38..141a7c404b 100644 --- a/skills/pascal-3d/SKILL.md +++ b/skills/pascal-3d/SKILL.md @@ -57,6 +57,8 @@ Record the active project ID, scene ID or version, and graph hash when returned. For construction, prefer tools such as `create_story_shell`, `create_room`, `add_door`, `add_window`, `create_roof`, `furnish_room`, and `place_item`. Use `apply_patch` only when no semantic tool expresses the requested edit and you have inspected the relevant node schema or an existing node of the same type. +When those tools and the catalog cannot reproduce something faithfully (custom columns, a porch, lanterns, a garage door, a vaulted or tray ceiling), build it with `add_object`: a plain three.js module, with the naming conventions its description lists for paint slots, parts, lights, cutouts and clips. Edit it by passing its `nodeId`. Servers without a script host answer `scripts_unavailable`. To count or locate things by what they are, including typed parts inside authored objects, use `find_by_type`. + Pascal uses meters. X and Z are floor-plan axes; Y is vertical. Tool fields that accept measurements may also accept strings such as `"6 ft"` or `"180cm"`, but report final spatial values in meters and retain the user's original units when useful. Preserve unrelated nodes. Before a bounded edit, identify the target IDs with `find_nodes`, `get_node`, `get_level_summary`, `get_walls`, or `get_zones`. After the edit, identify the actual changed IDs from tool output or a before/after read. diff --git a/wiki/architecture/README.md b/wiki/architecture/README.md index d1d11cd618..88b7ec9f5a 100644 --- a/wiki/architecture/README.md +++ b/wiki/architecture/README.md @@ -15,6 +15,7 @@ Canonical rules for code that touches `packages/core`, `packages/viewer`, `packa | [plugin-authoring](plugin-authoring.md) | Public contract for external plugins — `Plugin` shape, `setPluginDiscovery`, lifecycle, what's in and out of v1 | | [tools](tools.md) | Editor tools structure, 2D↔3D behavioral parity, manipulation constraints, and the snapping rule (Shift taps cycle the mode, Alt hold forces) | | [agent-surfaces](agent-surfaces.md) | MCP ↔ hosted AI chat ↔ published skill parity: shared contracts and operations, knowledge parity, tells of drift | +| [authored-objects](authored-objects.md) | Items an agent writes as three.js: script source, artifacts, naming conventions, surfaces, clips, compile hosts | | [measurements](measurements.md) | Persistent measurement data, 2D/3D draft ownership, snapping, units, and visibility | | [interaction-scope](interaction-scope.md) | The authoritative interaction state machine ("the spine"): `InteractionScope` union, the begin/update/end/endIf contract, the raycast hot-set, and the overlay scope matrix | | [viewer-isolation](viewer-isolation.md) | Keeping `@pascal-app/viewer` editor-agnostic | diff --git a/wiki/architecture/authored-objects.md b/wiki/architecture/authored-objects.md new file mode 100644 index 0000000000..f1bfc455f5 --- /dev/null +++ b/wiki/architecture/authored-objects.md @@ -0,0 +1,46 @@ +# Authored objects + +*Geometry an agent writes as plain three.js, kept as an `item` with a script source.* + +Applies to: `packages/geometry-script/**`, `item.source` in `packages/core/src/schema/nodes/item.ts`, `packages/core/src/agent-operations/author-object.ts`, `packages/core/src/lib/geometry-surfaces.ts`. + +## The model + +An authored object is an `item` whose `source` holds the hash of its module (`script`), its `params`, the hash of the GLB it compiled to (`artifact`) and the `manifest` read from it. The code never rides in the scene: it is a `text/javascript` artifact that only people who may edit the project can read, so publishing geometry never publishes the code. The manifest stays inline because placement, cuts and queries read it synchronously; the compiler keeps it under 24 KiB (outlines thinned, then the smallest surfaces dropped). `asset.src` is `artifact://` and `asset.dimensions` are the compiled bounds, so everything items already do (paint, hosting, lights, the move tool, plan footprint, collections, bake, export) applies unchanged. A catalog item is the same node without `source`. + +A `window` or `door` takes the same `source` when its fields cannot express the design (a fan grille, tracery, a carved leaf): `add_window`/`add_door` accept `code` and `params`. Everything script-side is shared with items: the renderer shows the artifact through the item's model path (`ScriptedOpeningModel`), the wall cuts its `cutout` mesh, the Parameters panel replaces the parametric frame's fields, an `open` clip is the Open control, and `get_source` reads it. It is created and rebuilt through its own tool (`add_window`/`add_door`, with `nodeId` to rebuild), where the opening's guidance lives; `add_object` is for objects and points an opening's id there. The kind keeps what makes it an opening: mark, schedule row, plan symbol, opening rules, `IfcWindow`/`IfcDoor`. Width and height are the compiled bounds; params named `width` and `height` are its size controls. + +The artifact is the truth: the module runs again only when its code, params or host inputs change, never on view, publish or bake. Where artifacts live is the host's choice through `configureArtifactStore` (in-memory by default). + +## The module + +```js +import * as THREE from 'three' +export const params = { width: { default: 4.8, min: 3, max: 8, step: 0.1, unit: 'm' } } +export const mount = 'floor' // 'floor' | 'wall' | 'wall-side' | 'ceiling' +export default function build({ params, THREE }) { /* … */ return group } +``` + +Allowed imports: `three`, `three/addons/…` (BufferGeometryUtils, RoundedBoxGeometry, ConvexGeometry, LoftGeometry, ParametricGeometry) and `three-bvh-csg`. No network, DOM, `eval`, dynamic import or `process`. + +## Naming conventions read into the manifest + +| In the output | Becomes | +| --- | --- | +| material `slot_` | paint slot (other materials become slots by name) | +| object `part:`, `userData.type` | addressable, typed part with bounds (`find_by_type`) | +| a three.js light, or `light:` | switchable light effect | +| mesh `cutout` | the opening it cuts in its host wall, in its real shape | +| empty `anchor:` | socket | +| mesh `collider` | walkthrough proxy (hidden) | +| clips on `group.animations` | `open` → open/close toggle (`close` or `open` reversed), `loop` → always on, any other name → its own play toggle | + +The compiler also derives upward surfaces (where things rest) and undersides (where ceiling items hang, sloped ones as planes); placement and rebuilds use them. Clips are sampled into position, quaternion and scale tracks, the only ones glTF keeps. + +## Compiling + +`@pascal-app/geometry-script` compiles a module to a GLB and manifest in a browser worker, Bun or Node, and returns both hashes. The host decides the isolation: the editor runs it in a worker with network and storage removed; an MCP server receives a `GeometryScriptHost` (compile, store, read) or answers `scripts_unavailable`. The default in-memory artifact store lasts one session; a host that persists scenes configures its own. + +## Agent tools + +`add_object` (create, or edit by `nodeId`: new code, or params alone to rebuild the stored script), `get_source` (the module and its params, for an edit) and `find_by_type` (nodes and typed parts of one type) are shared contracts in `@pascal-app/core/agent-tools`, one operation each; only the compile step differs per surface. See [agent-surfaces.md](agent-surfaces.md).