From 34ead4eccd5c68586879c5f6510c0aac423a5f7c Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 1 Oct 2026 17:28:40 -0400 Subject: [PATCH 01/28] feat(geometry-script): items authored as three.js scripts, compiled to content-addressed artifacts - @pascal-app/geometry-script: compiles a plain three.js module (allowlisted three/addons + three-bvh-csg imports) to a GLB + manifest read from naming conventions (part:, slot_, light:, anchor:, cutout, collider); runs in a browser worker, Bun or Node. - core: GeometryScriptSource on item.source; artifact:// URLs resolved through a host-configured ArtifactStore (in-memory by default). - editor: locked-down compile worker + authorScriptItem. - nodes: item renderer hides collider meshes like cutout. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QUGvAXaawxPa5rnLSE53iZ --- packages/core/src/index.ts | 9 + packages/core/src/lib/artifact-store.ts | 48 +++ packages/core/src/schema/asset-url.ts | 6 +- packages/core/src/schema/geometry-source.ts | 77 ++++ packages/core/src/schema/index.ts | 7 + packages/core/src/schema/nodes/item.ts | 6 + packages/editor/package.json | 1 + packages/editor/src/index.tsx | 6 + .../editor/src/lib/geometry-script/author.ts | 67 +++ .../editor/src/lib/geometry-script/client.ts | 63 +++ .../geometry-script/geometry-script.worker.ts | 65 +++ .../src/lib/geometry-script/protocol.ts | 12 + packages/geometry-script/package.json | 46 ++ packages/geometry-script/src/compile.ts | 401 ++++++++++++++++++ packages/geometry-script/src/index.ts | 9 + packages/geometry-script/src/item.ts | 122 ++++++ .../geometry-script/src/three-addons.d.ts | 10 + packages/geometry-script/src/transform.ts | 80 ++++ packages/geometry-script/tsconfig.json | 13 + packages/nodes/src/item/renderer.tsx | 8 +- packages/viewer/src/lib/asset-url.ts | 7 +- 21 files changed, 1056 insertions(+), 7 deletions(-) create mode 100644 packages/core/src/lib/artifact-store.ts create mode 100644 packages/core/src/schema/geometry-source.ts create mode 100644 packages/editor/src/lib/geometry-script/author.ts create mode 100644 packages/editor/src/lib/geometry-script/client.ts create mode 100644 packages/editor/src/lib/geometry-script/geometry-script.worker.ts create mode 100644 packages/editor/src/lib/geometry-script/protocol.ts create mode 100644 packages/geometry-script/package.json create mode 100644 packages/geometry-script/src/compile.ts create mode 100644 packages/geometry-script/src/index.ts create mode 100644 packages/geometry-script/src/item.ts create mode 100644 packages/geometry-script/src/three-addons.d.ts create mode 100644 packages/geometry-script/src/transform.ts create mode 100644 packages/geometry-script/tsconfig.json diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 07679cf40c..aef76ce775 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, diff --git a/packages/core/src/lib/artifact-store.ts b/packages/core/src/lib/artifact-store.ts new file mode 100644 index 0000000000..07e954c1fc --- /dev/null +++ b/packages/core/src/lib/artifact-store.ts @@ -0,0 +1,48 @@ +/** + * 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, mimeType: string) => Promise +} + +const memory = new Map() + +const memoryStore: ArtifactStore = { + url: (sha256) => memory.get(sha256) ?? null, + put: async (sha256, bytes, mimeType) => { + if (memory.has(sha256)) return + memory.set(sha256, URL.createObjectURL(new Blob([bytes], { type: mimeType }))) + }, +} + +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/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..e56b14e3dc --- /dev/null +++ b/packages/core/src/schema/geometry-source.ts @@ -0,0 +1,77 @@ +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 + +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() })).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([]), + cutout: z.boolean().default(false), + collider: z.boolean().default(false), + triangles: z.number().int().nonnegative(), +}) +export type GeometryArtifactManifest = z.infer + +/** + * 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'), + code: z + .string() + .min(1) + .refine( + (code) => new TextEncoder().encode(code).byteLength <= GEOMETRY_SCRIPT_MAX_BYTES, + `Script source exceeds ${GEOMETRY_SCRIPT_MAX_BYTES / 1024} KiB`, + ), + 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..0f57ba4470 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -24,6 +24,13 @@ export { } from './compiled-node-parsers' // Cut intents (F5b) export { CutIntent, CutShape } from './cut' +export { + GEOMETRY_SCRIPT_MAX_BYTES, + GeometryArtifactManifest, + GeometryScriptParamSpec, + GeometryScriptParamValue, + GeometryScriptSource, +} from './geometry-source' export type { MaterialMapProperties, MaterialMaps, diff --git a/packages/core/src/schema/nodes/item.ts b/packages/core/src/schema/nodes/item.ts index b04a21fad3..7b08fe3060 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 --- @@ -170,6 +171,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 +188,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 (code, params, artifact hash, manifest) `) export type ItemNode = z.infer 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/index.tsx b/packages/editor/src/index.tsx index 7435b4486f..5f4051e94c 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 { + type AuthorScriptItemInput, + type AuthorScriptItemResult, + authorScriptItem, +} 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..b95280c481 --- /dev/null +++ b/packages/editor/src/lib/geometry-script/author.ts @@ -0,0 +1,67 @@ +import { + type AnyNodeId, + type GeometryArtifactManifest, + type GeometryScriptParamValue, + getArtifactStore, + type ItemNode, + useScene, +} from '@pascal-app/core' +import { + createScriptItem, + type GeometryScriptMount, + type ScriptItemFields, + updateScriptItem, +} from '@pascal-app/geometry-script' +import { compileGeometryScriptInWorker } from './client' + +export type AuthorScriptItemInput = ScriptItemFields & { + params?: Record + /** Update this item; omit to create one. */ + nodeId?: string + /** Required on create: the level, wall, ceiling or item that hosts it. */ + parentId?: string +} + +export type AuthorScriptItemResult = { + nodeId: string + sha256: string + mount: GeometryScriptMount + params: Record + manifest: GeometryArtifactManifest +} + +/** + * Compiles a three.js geometry script in the worker, stores the artifact and + * creates or updates the item that references it. + */ +export async function authorScriptItem( + input: AuthorScriptItemInput, +): Promise { + const scene = useScene.getState() + const existing = input.nodeId ? scene.nodes[input.nodeId as AnyNodeId] : undefined + if (input.nodeId && existing?.type !== 'item') throw new Error(`No item with id ${input.nodeId}`) + const previous = existing as ItemNode | undefined + const parent = input.parentId ? scene.nodes[input.parentId as AnyNodeId] : undefined + if (!previous && !parent) { + throw new Error(input.parentId ? `No node with id ${input.parentId}` : 'parentId is required') + } + + const output = await compileGeometryScriptInWorker({ + code: input.code, + params: input.params ?? previous?.source?.params, + }) + await getArtifactStore().put(output.sha256, output.glb, 'model/gltf-binary') + + const node = previous + ? updateScriptItem(previous, output, input) + : createScriptItem(output, input, parent!) + if (previous) scene.updateNode(previous.id as AnyNodeId, node) + else scene.createNode(node, parent!.id as AnyNodeId) + return { + nodeId: node.id, + sha256: output.sha256, + mount: output.mount, + params: output.params, + manifest: output.manifest, + } +} 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..8fef209e6c --- /dev/null +++ b/packages/editor/src/lib/geometry-script/client.ts @@ -0,0 +1,63 @@ +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 + +type Pending = { + resolve: (output: GeometryScriptCompileOutput) => void + reject: (error: Error) => void + timeout: ReturnType +} + +let worker: Worker | null = null +let nextId = 1 +const pending = new Map() + +function reset(error: Error) { + worker?.terminate() + worker = null + for (const entry of pending.values()) { + clearTimeout(entry.timeout) + entry.reject(error) + } + pending.clear() +} + +function getWorker(): Worker { + if (worker) return worker + if (typeof Worker === 'undefined') throw new Error('Web Workers are unavailable here') + worker = new Worker(new URL('./geometry-script.worker.ts', import.meta.url), { type: 'module' }) + worker.addEventListener('message', (event: MessageEvent) => { + const entry = pending.get(event.data.id) + if (!entry) return + pending.delete(event.data.id) + clearTimeout(entry.timeout) + if (event.data.ok) entry.resolve(event.data.output) + else entry.reject(new Error(event.data.error)) + }) + worker.addEventListener('error', (event) => { + reset(new Error(event.message || 'The geometry script worker crashed')) + }) + return worker +} + +/** + * Runs a geometry script in the worker and returns its GLB + manifest. A + * script that hangs is killed with its worker; the next compile starts fresh. + */ +export function compileGeometryScriptInWorker(input: { + code: string + params?: Record +}): Promise { + const active = getWorker() + 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, { resolve, reject, timeout }) + active.postMessage(request) + }) +} diff --git a/packages/editor/src/lib/geometry-script/geometry-script.worker.ts b/packages/editor/src/lib/geometry-script/geometry-script.worker.ts new file mode 100644 index 0000000000..3f5366abbf --- /dev/null +++ b/packages/editor/src/lib/geometry-script/geometry-script.worker.ts @@ -0,0 +1,65 @@ +/// +import { compileGeometryScript } from '@pascal-app/geometry-script' +import type { GeometryScriptWorkerRequest, GeometryScriptWorkerResponse } from './protocol' + +// Model-written code runs in this worker. Before any of it runs, remove the +// ambient capabilities a geometry build never needs: network, storage, +// nested workers and script loading. This is a deterrent, not a sandbox: +// the isolation boundary is an opaque-origin host with a CSP (not built yet). +const BLOCKED = [ + 'fetch', + 'XMLHttpRequest', + 'WebSocket', + 'WebTransport', + 'EventSource', + 'importScripts', + 'indexedDB', + 'caches', + 'Worker', + 'SharedWorker', + 'BroadcastChannel', + 'Request', + 'Response', +] as const + +const scope = self as unknown as Record +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 {} + } + } +} +try { + Object.defineProperty(scope.navigator as object, 'sendBeacon', { value: undefined }) +} catch {} + +const SOURCE_GUARD = /\bimport\s*\(|\beval\s*\(|\bFunction\s*\(|\.constructor\s*\(/ + +self.addEventListener('message', async (event: MessageEvent) => { + const { id, code, params } = event.data + try { + if (SOURCE_GUARD.test(code)) { + throw new Error( + 'Dynamic import, eval and Function constructors are not available in geometry scripts', + ) + } + const output = await compileGeometryScript({ code, params }) + const response: GeometryScriptWorkerResponse = { id, ok: true, output } + post(response, [output.glb]) + } catch (error) { + const response: GeometryScriptWorkerResponse = { + id, + ok: false, + error: error instanceof Error ? error.message : String(error), + } + post(response) + } +}) 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/geometry-script/package.json b/packages/geometry-script/package.json new file mode 100644 index 0000000000..5d1df05a39 --- /dev/null +++ b/packages/geometry-script/package.json @@ -0,0 +1,46 @@ +{ + "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" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc --build", + "dev": "tsgo --build --watch", + "check-types": "tsgo --noEmit", + "prepublishOnly": "npm run build" + }, + "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/src/compile.ts b/packages/geometry-script/src/compile.ts new file mode 100644 index 0000000000..da718e6c5c --- /dev/null +++ b/packages/geometry-script/src/compile.ts @@ -0,0 +1,401 @@ +import type { + GeometryArtifactManifest, + GeometryScriptParamSpec, + GeometryScriptParamValue, +} from '@pascal-app/core' +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 = 'floor' | 'wall' | 'wall-side' | 'ceiling' + +export type GeometryScriptCompileInput = { + code: string + params?: Record +} + +export type GeometryScriptCompileOutput = { + glb: ArrayBuffer + sha256: string + mount: GeometryScriptMount + params: Record + manifest: GeometryArtifactManifest +} + +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, + }) + 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, + } +} + +// 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): Promise { + ensureFileReader() + const exporter = new GLTFExporter() + const result = await exporter.parseAsync(root, { binary: true, onlyVisible: false }) + if (!(result instanceof ArrayBuffer)) + throw new Error('GLB export returned JSON instead of binary') + return result +} + +async function digest(bytes: ArrayBuffer): Promise { + const hash = await crypto.subtle.digest('SHA-256', bytes) + return Array.from(new Uint8Array(hash), (b) => b.toString(16).padStart(2, '0')).join('') +} + +export async function compileGeometryScript( + input: GeometryScriptCompileInput, +): Promise { + 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 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 bounds = visibleBounds(root) + const glb = await exportGlb(root) + return { + glb, + sha256: await digest(glb), + mount, + params, + manifest: { + bounds: { min: vec(bounds.min), max: vec(bounds.max) }, + params: specs, + parts: conventions.parts, + slots: conventions.slots, + anchors: conventions.anchors, + lights: conventions.lights, + 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..c8a6e27366 --- /dev/null +++ b/packages/geometry-script/src/index.ts @@ -0,0 +1,9 @@ +export { + compileGeometryScript, + GEOMETRY_SCRIPT_LIMITS, + type GeometryScriptCompileInput, + type GeometryScriptCompileOutput, + type GeometryScriptMount, +} from './compile' +export { createScriptItem, type ScriptItemFields, updateScriptItem } from './item' +export { moduleKey, transformModule } from './transform' diff --git a/packages/geometry-script/src/item.ts b/packages/geometry-script/src/item.ts new file mode 100644 index 0000000000..71082940c7 --- /dev/null +++ b/packages/geometry-script/src/item.ts @@ -0,0 +1,122 @@ +import { + type AnyNode, + artifactUrl, + type GeometryScriptParamValue, + generateId, + ItemNode, +} from '@pascal-app/core' +import type { GeometryScriptCompileOutput, GeometryScriptMount } from './compile' + +type Vec3 = [number, number, number] + +export type ScriptItemFields = { + code: string + name?: string + /** What the object is ("column", "lantern", "transom"): the item's category. */ + category?: string + position?: Vec3 + rotation?: Vec3 + /** Which wall face a wall-side object sits on. */ + side?: 'front' | 'back' +} + +const ATTACH: Record = { + floor: undefined, + wall: 'wall', + 'wall-side': 'wall-side', + ceiling: 'ceiling', +} + +function scriptAsset( + output: GeometryScriptCompileOutput, + fields: ScriptItemFields, + previous: ItemNode['asset'] | undefined, +): ItemNode['asset'] { + const { min, max } = output.manifest.bounds + const lights = output.manifest.lights + return { + id: `script_${output.sha256.slice(0, 16)}`, + category: fields.category ?? previous?.category ?? 'object', + name: fields.name ?? previous?.name ?? 'Scripted object', + thumbnail: previous?.thumbnail ?? '', + source: 'mine', + src: artifactUrl(output.sha256), + dimensions: [max[0] - min[0], max[1] - min[1], max[2] - min[2]], + attachTo: ATTACH[output.mount], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + interactive: + lights.length > 0 + ? { + controls: [{ kind: 'toggle', label: 'Lights', default: true }], + effects: lights.map((light) => ({ + kind: 'light' as const, + color: light.color, + intensityRange: [0, light.intensity] as [number, number], + distance: light.distance, + offset: light.position, + })), + } + : undefined, + } +} + +function scriptSource(output: GeometryScriptCompileOutput, code: string) { + return { + kind: 'script' as const, + language: 'three' as const, + code, + params: output.params as Record, + artifact: output.sha256, + manifest: output.manifest, + } +} + +/** + * The item a compiled script becomes: it references the artifact by hash and + * takes its bounds as dimensions; lights become the item's light effects. + */ +export function createScriptItem( + output: GeometryScriptCompileOutput, + fields: ScriptItemFields, + parent: AnyNode, +): ItemNode { + const asset = scriptAsset(output, fields, undefined) + return ItemNode.parse({ + object: 'node', + id: generateId('item'), + type: 'item', + name: fields.name ?? asset.name, + parentId: parent.id, + ...(parent.type === 'wall' ? { wallId: parent.id, side: fields.side ?? 'front' } : {}), + position: fields.position ?? [0, 0, 0], + rotation: fields.rotation ?? [0, 0, 0], + source: scriptSource(output, fields.code), + asset, + }) +} + +/** + * The same item after a re-compile: identity, placement, children and paint + * survive; paint on a slot the new output no longer has is dropped. + */ +export function updateScriptItem( + previous: ItemNode, + output: GeometryScriptCompileOutput, + fields: ScriptItemFields, +): ItemNode { + const slotIds = new Set(output.manifest.slots.map((slot) => slot.id)) + return ItemNode.parse({ + ...previous, + name: fields.name ?? previous.name, + position: fields.position ?? previous.position, + rotation: fields.rotation ?? previous.rotation, + side: fields.side ?? previous.side, + slots: previous.slots + ? Object.fromEntries(Object.entries(previous.slots).filter(([id]) => slotIds.has(id))) + : undefined, + source: scriptSource(output, fields.code), + asset: scriptAsset(output, fields, previous.asset), + }) +} 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/nodes/src/item/renderer.tsx b/packages/nodes/src/item/renderer.tsx index dad426520b..b3e7e90a1e 100644 --- a/packages/nodes/src/item/renderer.tsx +++ b/packages/nodes/src/item/renderer.tsx @@ -644,12 +644,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 = { 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 Date: Thu, 1 Oct 2026 17:55:50 -0400 Subject: [PATCH 02/28] fix(geometry-script): worker loads three under Turbopack; compile-only subpath Turbopack folds three's typeof-window guard to true in worker chunks, so the worker defines window before three evaluates; the worker imports the compile subpath so core (which needs window) stays out of it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QUGvAXaawxPa5rnLSE53iZ --- .../src/lib/geometry-script/geometry-script.worker.ts | 4 +++- .../editor/src/lib/geometry-script/worker-window-shim.ts | 5 +++++ packages/geometry-script/package.json | 7 ++++++- 3 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 packages/editor/src/lib/geometry-script/worker-window-shim.ts diff --git a/packages/editor/src/lib/geometry-script/geometry-script.worker.ts b/packages/editor/src/lib/geometry-script/geometry-script.worker.ts index 3f5366abbf..4f3f8e9e34 100644 --- a/packages/editor/src/lib/geometry-script/geometry-script.worker.ts +++ b/packages/editor/src/lib/geometry-script/geometry-script.worker.ts @@ -1,5 +1,7 @@ /// -import { compileGeometryScript } from '@pascal-app/geometry-script' +import './worker-window-shim' +// The compile subpath only: the package index pulls in core, which needs `window`. +import { compileGeometryScript } from '@pascal-app/geometry-script/compile' import type { GeometryScriptWorkerRequest, GeometryScriptWorkerResponse } from './protocol' // Model-written code runs in this worker. Before any of it runs, remove the diff --git a/packages/editor/src/lib/geometry-script/worker-window-shim.ts b/packages/editor/src/lib/geometry-script/worker-window-shim.ts new file mode 100644 index 0000000000..469649b5f1 --- /dev/null +++ b/packages/editor/src/lib/geometry-script/worker-window-shim.ts @@ -0,0 +1,5 @@ +// Turbopack compiles worker modules as browser code and folds three's +// `typeof window !== 'undefined'` guard to true, so `window` must exist before +// three evaluates. Imported first by the worker. +const scope = globalThis as { window?: unknown } +if (scope.window === undefined) scope.window = globalThis diff --git a/packages/geometry-script/package.json b/packages/geometry-script/package.json index 5d1df05a39..5956f4118e 100644 --- a/packages/geometry-script/package.json +++ b/packages/geometry-script/package.json @@ -1,7 +1,7 @@ { "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.", + "description": "Compiles AI-authored three.js geometry scripts into GLB artifacts with a Pascal manifest. Runs in a browser worker, Bun or Node \u2014 no DOM, no React.", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -10,6 +10,11 @@ "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" } }, "files": [ From fb0dd73403015de137a08a09848ddc4cb734a913 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 2 Oct 2026 09:01:30 -0400 Subject: [PATCH 03/28] feat(agent-tools): author_object is a shared chat/MCP tool; typed parts and resting surfaces - core: authorObjectTool contract + authorObject operation (the item a compiled module becomes; paint on vanished slots kept and reported; parts cap; host check by mount). Surfaces compile first and pass `compiled`. - geometry-script: manifest parts carry userData.type and bounds; upward surfaces per part ({ part, y, polygon }) for placement; source guard moves into the compiler; item building moves to core. - mcp: author_object registered from the shared contract; the host supplies compile + artifact storage (GeometryScriptHost), else scripts_unavailable. Annotation policy: destructive (an edit replaces geometry), 66 tools. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QUGvAXaawxPa5rnLSE53iZ --- .../src/agent-operations/author-object.ts | 190 ++++++++++++++++++ packages/core/src/agent-operations/index.ts | 1 + .../core/src/agent-tools/author-object.ts | 64 ++++++ packages/core/src/agent-tools/index.ts | 3 + packages/core/src/schema/geometry-source.ts | 33 ++- packages/core/src/schema/index.ts | 2 + packages/editor/src/index.tsx | 6 +- .../editor/src/lib/geometry-script/author.ts | 69 +------ .../geometry-script/geometry-script.worker.ts | 7 - packages/geometry-script/src/compile.ts | 120 ++++++++++- packages/geometry-script/src/index.ts | 1 - packages/geometry-script/src/item.ts | 122 ----------- packages/mcp/src/index.ts | 1 + packages/mcp/src/server.ts | 5 +- packages/mcp/src/tools/author-object.ts | 97 +++++++++ packages/mcp/src/tools/index.ts | 8 +- .../src/tools/read-tool-annotations.test.ts | 3 +- packages/mcp/src/tools/shared-tools.ts | 2 +- .../tool-annotation-justifications.json | 13 ++ scripts/openai-tool-annotation-policy.test.ts | 2 +- scripts/openai-tool-annotation-policy.ts | 1 + 21 files changed, 543 insertions(+), 207 deletions(-) create mode 100644 packages/core/src/agent-operations/author-object.ts create mode 100644 packages/core/src/agent-tools/author-object.ts delete mode 100644 packages/geometry-script/src/item.ts create mode 100644 packages/mcp/src/tools/author-object.ts diff --git a/packages/core/src/agent-operations/author-object.ts b/packages/core/src/agent-operations/author-object.ts new file mode 100644 index 0000000000..0ffae05aa2 --- /dev/null +++ b/packages/core/src/agent-operations/author-object.ts @@ -0,0 +1,190 @@ +import { refuse } from '../agent-tools/refusal' +import { artifactUrl } from '../lib/artifact-store' +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] + +/** How many named parts one object may carry; past this it is several objects. */ +export const AUTHORED_OBJECT_MAX_PARTS = 64 + +export type AuthorObjectInput = { + 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: AuthorObjectInput, + previous: ItemNode['asset'] | undefined, +): ItemNode['asset'] { + const { min, max } = compiled.manifest.bounds + const lights = compiled.manifest.lights + 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], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + interactive: + lights.length > 0 + ? { + controls: [{ kind: 'toggle', label: 'Lights', default: true }], + effects: lights.map((light) => ({ + kind: 'light' as const, + color: light.color, + intensityRange: [0, light.intensity] as [number, number], + distance: light.distance, + offset: light.position, + })), + } + : undefined, + } +} + +const scriptSource = (compiled: CompiledGeometryScript, code: string) => ({ + kind: 'script' as const, + language: 'three' as const, + code, + params: compiled.params, + artifact: compiled.sha256, + manifest: compiled.manifest, +}) + +const round = (value: number) => Math.round(value * 1000) / 1000 + +function summary(node: ItemNode, compiled: CompiledGeometryScript, orphanedSlots: string[]) { + const { bounds, parts, slots, lights, params, triangles, cutout } = 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), + 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.`, + } + : {}), + } +} + +/** + * `author_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 authorObject: AgentOperation = (nodes, input, context) => { + const { compiled } = input + if (compiled.manifest.parts.length > AUTHORED_OBJECT_MAX_PARTS) { + refuse( + 'too_many_parts', + `The object has ${compiled.manifest.parts.length} parts; at most ${AUTHORED_OBJECT_MAX_PARTS}. Group detail into fewer parts, or build separate objects for things that are separate.`, + { parts: compiled.manifest.parts.length }, + ) + } + const rotation: Vec3 | undefined = + input.rotation === undefined ? undefined : [0, (input.rotation * Math.PI) / 180, 0] + + if (input.nodeId) { + const previous = nodes[input.nodeId] + if (!previous) + refuse('node_not_found', `Node not found: ${input.nodeId}.`, { id: input.nodeId }) + if (previous.type !== 'item' || !previous.source) { + refuse( + 'not_authored', + `${input.nodeId} is a ${previous.type} without a script; only objects built with author_object can be edited this way.`, + { 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)) + 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, input.code), + asset: scriptAsset(compiled, input, previous.asset), + }) + return { + result: summary(next, compiled, orphanedSlots), + changes: { update: [{ id: next.id, data: next }] }, + } + } + + 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, input.code), + asset, + }) + return { + result: summary(node, compiled, []), + changes: { create: [{ node, parentId: parent.id }] }, + } +} diff --git a/packages/core/src/agent-operations/index.ts b/packages/core/src/agent-operations/index.ts index 170df51ad3..1f00b72d8f 100644 --- a/packages/core/src/agent-operations/index.ts +++ b/packages/core/src/agent-operations/index.ts @@ -6,6 +6,7 @@ import { listLevels } from './list-levels' import { verifyScene } from './verify-scene' export * from './apply-changes' +export * from './author-object' export * from './delete-node' export * from './door-clearance' export * from './duplicate-level' diff --git a/packages/core/src/agent-tools/author-object.ts b/packages/core/src/agent-tools/author-object.ts new file mode 100644 index 0000000000..0f1c6afc11 --- /dev/null +++ b/packages/core/src/agent-tools/author-object.ts @@ -0,0 +1,64 @@ +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 * as THREE from 'three' + // 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. +- Wall opening: a box mesh named cutout (wall mount) is cut out of the host wall and never renders. +- 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; identity, placement and paint are kept. The result lists the size, parts, slots, lights and params.` + +export const authorObjectTool = { + name: 'author_object', + title: 'Build object', + description: DESCRIPTION, + input: { + code: z.string().min(1).max(48_000).describe('The three.js module (see the tool description).'), + 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").'), + }, +} diff --git a/packages/core/src/agent-tools/index.ts b/packages/core/src/agent-tools/index.ts index 1ca7de673e..dea6ab2b4d 100644 --- a/packages/core/src/agent-tools/index.ts +++ b/packages/core/src/agent-tools/index.ts @@ -1,3 +1,4 @@ +import { authorObjectTool } from './author-object' import { duplicateLevelTool, getLevelSummaryTool, @@ -9,6 +10,7 @@ import { import { deleteNodeTool, getNodeTool } from './nodes' import { addDoorTool, addWindowTool } from './wall-openings' +export * from './author-object' export * from './levels' export * from './measurement' export { NodeId } from './node-id' @@ -32,4 +34,5 @@ export const AGENT_TOOL_CONTRACTS = [ duplicateLevelTool, verifySceneTool, deleteNodeTool, + authorObjectTool, ] as const diff --git a/packages/core/src/schema/geometry-source.ts b/packages/core/src/schema/geometry-source.ts index e56b14e3dc..28017ff945 100644 --- a/packages/core/src/schema/geometry-source.ts +++ b/packages/core/src/schema/geometry-source.ts @@ -32,7 +32,27 @@ export type GeometryScriptParamSpec = z.infer 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() })).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() })) @@ -54,6 +74,17 @@ export const GeometryArtifactManifest = z.object({ }) 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: 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 diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 0f57ba4470..073ecd84f8 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -25,8 +25,10 @@ export { // Cut intents (F5b) export { CutIntent, CutShape } from './cut' export { + type CompiledGeometryScript, GEOMETRY_SCRIPT_MAX_BYTES, GeometryArtifactManifest, + GeometryScriptMount, GeometryScriptParamSpec, GeometryScriptParamValue, GeometryScriptSource, diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 5f4051e94c..4fd01f6a09 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -577,11 +577,7 @@ export { commitFreshPlacementSubtree, createFreshPlacementSubtree, } from './lib/fresh-planar-placement' -export { - type AuthorScriptItemInput, - type AuthorScriptItemResult, - authorScriptItem, -} from './lib/geometry-script/author' +export { compileAndStoreGeometryScript } from './lib/geometry-script/author' export { compileGeometryScriptInWorker } from './lib/geometry-script/client' export { exportSceneToGlb, type GlbExportOptions } from './lib/glb-export' export { diff --git a/packages/editor/src/lib/geometry-script/author.ts b/packages/editor/src/lib/geometry-script/author.ts index b95280c481..4ed6e812fe 100644 --- a/packages/editor/src/lib/geometry-script/author.ts +++ b/packages/editor/src/lib/geometry-script/author.ts @@ -1,67 +1,20 @@ import { - type AnyNodeId, - type GeometryArtifactManifest, + type CompiledGeometryScript, type GeometryScriptParamValue, getArtifactStore, - type ItemNode, - useScene, } from '@pascal-app/core' -import { - createScriptItem, - type GeometryScriptMount, - type ScriptItemFields, - updateScriptItem, -} from '@pascal-app/geometry-script' import { compileGeometryScriptInWorker } from './client' -export type AuthorScriptItemInput = ScriptItemFields & { - params?: Record - /** Update this item; omit to create one. */ - nodeId?: string - /** Required on create: the level, wall, ceiling or item that hosts it. */ - parentId?: string -} - -export type AuthorScriptItemResult = { - nodeId: string - sha256: string - mount: GeometryScriptMount - params: Record - manifest: GeometryArtifactManifest -} - /** - * Compiles a three.js geometry script in the worker, stores the artifact and - * creates or updates the item that references it. + * The editor's compile step for `author_object`: runs the module in the + * worker and stores the artifact, so the core operation can reference it by + * hash. The GLB bytes stay out of the result. */ -export async function authorScriptItem( - input: AuthorScriptItemInput, -): Promise { - const scene = useScene.getState() - const existing = input.nodeId ? scene.nodes[input.nodeId as AnyNodeId] : undefined - if (input.nodeId && existing?.type !== 'item') throw new Error(`No item with id ${input.nodeId}`) - const previous = existing as ItemNode | undefined - const parent = input.parentId ? scene.nodes[input.parentId as AnyNodeId] : undefined - if (!previous && !parent) { - throw new Error(input.parentId ? `No node with id ${input.parentId}` : 'parentId is required') - } - - const output = await compileGeometryScriptInWorker({ - code: input.code, - params: input.params ?? previous?.source?.params, - }) - await getArtifactStore().put(output.sha256, output.glb, 'model/gltf-binary') - - const node = previous - ? updateScriptItem(previous, output, input) - : createScriptItem(output, input, parent!) - if (previous) scene.updateNode(previous.id as AnyNodeId, node) - else scene.createNode(node, parent!.id as AnyNodeId) - return { - nodeId: node.id, - sha256: output.sha256, - mount: output.mount, - params: output.params, - manifest: output.manifest, - } +export async function compileAndStoreGeometryScript(input: { + code: string + params?: Record +}): Promise { + const { glb, ...compiled } = await compileGeometryScriptInWorker(input) + await getArtifactStore().put(compiled.sha256, glb, 'model/gltf-binary') + return compiled } diff --git a/packages/editor/src/lib/geometry-script/geometry-script.worker.ts b/packages/editor/src/lib/geometry-script/geometry-script.worker.ts index 4f3f8e9e34..d20b6c1144 100644 --- a/packages/editor/src/lib/geometry-script/geometry-script.worker.ts +++ b/packages/editor/src/lib/geometry-script/geometry-script.worker.ts @@ -43,16 +43,9 @@ try { Object.defineProperty(scope.navigator as object, 'sendBeacon', { value: undefined }) } catch {} -const SOURCE_GUARD = /\bimport\s*\(|\beval\s*\(|\bFunction\s*\(|\.constructor\s*\(/ - self.addEventListener('message', async (event: MessageEvent) => { const { id, code, params } = event.data try { - if (SOURCE_GUARD.test(code)) { - throw new Error( - 'Dynamic import, eval and Function constructors are not available in geometry scripts', - ) - } const output = await compileGeometryScript({ code, params }) const response: GeometryScriptWorkerResponse = { id, ok: true, output } post(response, [output.glb]) diff --git a/packages/geometry-script/src/compile.ts b/packages/geometry-script/src/compile.ts index da718e6c5c..9bcd43d228 100644 --- a/packages/geometry-script/src/compile.ts +++ b/packages/geometry-script/src/compile.ts @@ -1,5 +1,7 @@ import type { + CompiledGeometryScript, GeometryArtifactManifest, + GeometryScriptMount, GeometryScriptParamSpec, GeometryScriptParamValue, } from '@pascal-app/core' @@ -14,20 +16,14 @@ import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUti import { ADDITION, Brush, DIFFERENCE, Evaluator, INTERSECTION, SUBTRACTION } from 'three-bvh-csg' import { type ModuleTable, transformModule } from './transform' -export type GeometryScriptMount = 'floor' | 'wall' | 'wall-side' | 'ceiling' +export type { GeometryScriptMount } export type GeometryScriptCompileInput = { code: string params?: Record } -export type GeometryScriptCompileOutput = { - glb: ArrayBuffer - sha256: string - mount: GeometryScriptMount - params: Record - manifest: GeometryArtifactManifest -} +export type GeometryScriptCompileOutput = CompiledGeometryScript & { glb: ArrayBuffer } export const GEOMETRY_SCRIPT_LIMITS = { triangles: 300_000, @@ -203,6 +199,7 @@ function readConventions(root: THREE.Object3D) { 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}` @@ -288,6 +285,102 @@ function readConventions(root: THREE.Object3D) { } } +const SURFACE_MIN_NORMAL_Y = 0.95 +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 part 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 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 || normal.y / (2 * area) < SURFACE_MIN_NORMAL_Y) continue + const y = Math.round(((a.y + b.y + c.y) / 3) * 100) / 100 + const key = `${part ?? ''}|${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) } + } + return [...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: convexHull(surface.points).map( + ([x, z]) => [Math.round(x * 1000) / 1000, Math.round(z * 1000) / 1000] as [number, number], + ), + })) +} + // GLTFExporter writes binaries through FileReader, which Bun and Node lack. function ensureFileReader() { const g = globalThis as { FileReader?: unknown } @@ -324,9 +417,18 @@ async function digest(bytes: ArrayBuffer): Promise { 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 (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 ( @@ -379,6 +481,7 @@ export async function compileGeometryScript( ) } + const surfaces = analyseGeometry(root, conventions.parts) const bounds = visibleBounds(root) const glb = await exportGlb(root) return { @@ -390,6 +493,7 @@ export async function compileGeometryScript( bounds: { min: vec(bounds.min), max: vec(bounds.max) }, params: specs, parts: conventions.parts, + surfaces, slots: conventions.slots, anchors: conventions.anchors, lights: conventions.lights, diff --git a/packages/geometry-script/src/index.ts b/packages/geometry-script/src/index.ts index c8a6e27366..9dab756ac4 100644 --- a/packages/geometry-script/src/index.ts +++ b/packages/geometry-script/src/index.ts @@ -5,5 +5,4 @@ export { type GeometryScriptCompileOutput, type GeometryScriptMount, } from './compile' -export { createScriptItem, type ScriptItemFields, updateScriptItem } from './item' export { moduleKey, transformModule } from './transform' diff --git a/packages/geometry-script/src/item.ts b/packages/geometry-script/src/item.ts deleted file mode 100644 index 71082940c7..0000000000 --- a/packages/geometry-script/src/item.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { - type AnyNode, - artifactUrl, - type GeometryScriptParamValue, - generateId, - ItemNode, -} from '@pascal-app/core' -import type { GeometryScriptCompileOutput, GeometryScriptMount } from './compile' - -type Vec3 = [number, number, number] - -export type ScriptItemFields = { - code: string - name?: string - /** What the object is ("column", "lantern", "transom"): the item's category. */ - category?: string - position?: Vec3 - rotation?: Vec3 - /** Which wall face a wall-side object sits on. */ - side?: 'front' | 'back' -} - -const ATTACH: Record = { - floor: undefined, - wall: 'wall', - 'wall-side': 'wall-side', - ceiling: 'ceiling', -} - -function scriptAsset( - output: GeometryScriptCompileOutput, - fields: ScriptItemFields, - previous: ItemNode['asset'] | undefined, -): ItemNode['asset'] { - const { min, max } = output.manifest.bounds - const lights = output.manifest.lights - return { - id: `script_${output.sha256.slice(0, 16)}`, - category: fields.category ?? previous?.category ?? 'object', - name: fields.name ?? previous?.name ?? 'Scripted object', - thumbnail: previous?.thumbnail ?? '', - source: 'mine', - src: artifactUrl(output.sha256), - dimensions: [max[0] - min[0], max[1] - min[1], max[2] - min[2]], - attachTo: ATTACH[output.mount], - offset: [0, 0, 0], - rotation: [0, 0, 0], - scale: [1, 1, 1], - interactive: - lights.length > 0 - ? { - controls: [{ kind: 'toggle', label: 'Lights', default: true }], - effects: lights.map((light) => ({ - kind: 'light' as const, - color: light.color, - intensityRange: [0, light.intensity] as [number, number], - distance: light.distance, - offset: light.position, - })), - } - : undefined, - } -} - -function scriptSource(output: GeometryScriptCompileOutput, code: string) { - return { - kind: 'script' as const, - language: 'three' as const, - code, - params: output.params as Record, - artifact: output.sha256, - manifest: output.manifest, - } -} - -/** - * The item a compiled script becomes: it references the artifact by hash and - * takes its bounds as dimensions; lights become the item's light effects. - */ -export function createScriptItem( - output: GeometryScriptCompileOutput, - fields: ScriptItemFields, - parent: AnyNode, -): ItemNode { - const asset = scriptAsset(output, fields, undefined) - return ItemNode.parse({ - object: 'node', - id: generateId('item'), - type: 'item', - name: fields.name ?? asset.name, - parentId: parent.id, - ...(parent.type === 'wall' ? { wallId: parent.id, side: fields.side ?? 'front' } : {}), - position: fields.position ?? [0, 0, 0], - rotation: fields.rotation ?? [0, 0, 0], - source: scriptSource(output, fields.code), - asset, - }) -} - -/** - * The same item after a re-compile: identity, placement, children and paint - * survive; paint on a slot the new output no longer has is dropped. - */ -export function updateScriptItem( - previous: ItemNode, - output: GeometryScriptCompileOutput, - fields: ScriptItemFields, -): ItemNode { - const slotIds = new Set(output.manifest.slots.map((slot) => slot.id)) - return ItemNode.parse({ - ...previous, - name: fields.name ?? previous.name, - position: fields.position ?? previous.position, - rotation: fields.rotation ?? previous.rotation, - side: fields.side ?? previous.side, - slots: previous.slots - ? Object.fromEntries(Object.entries(previous.slots).filter(([id]) => slotIds.has(id))) - : undefined, - source: scriptSource(output, fields.code), - asset: scriptAsset(output, fields, previous.asset), - }) -} diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index da2d734f6f..982eab8c30 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/author-object' export { version } from './version' diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index b07b747c69..921617667e 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/author-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 `author_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/author-object.ts b/packages/mcp/src/tools/author-object.ts new file mode 100644 index 0000000000..1debdb3225 --- /dev/null +++ b/packages/mcp/src/tools/author-object.ts @@ -0,0 +1,97 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { type AuthorObjectInput, authorObject } from '@pascal-app/core/agent-operations' +import { authorObjectTool } from '@pascal-app/core/agent-tools' +import type { + AnyNode, + CompiledGeometryScript, + GeometryScriptParamValue, +} from '@pascal-app/core/schema' +import type { SceneOperations } from '../operations' +import { DESTRUCTIVE_TOOL_ANNOTATIONS } from './annotations' +import { refusalResult, toolError } from './errors' +import { persistencePayload, publishLiveSceneSnapshot } from './live-sync' +import { toPatches } from './shared-tools' + +/** + * How a host runs `author_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 +} + +/** `author_object` on the MCP: the shared contract and operation, with the host's compile in front. */ +export function registerAuthorObject( + server: McpServer, + bridge: SceneOperations, + host: GeometryScriptHost | undefined, +): void { + server.registerTool( + authorObjectTool.name, + { + title: authorObjectTool.title, + description: authorObjectTool.description, + inputSchema: authorObjectTool.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 { glb, ...rest } = await host.compile({ code: args.code, params: args.params }) + await host.storeArtifact({ + sceneId: scene.id, + sha256: rest.sha256, + bytes: glb, + mimeType: 'model/gltf-binary', + }) + compiled = rest + } catch (error) { + return toolError(error instanceof Error ? error.message : String(error), { + code: 'script_failed', + }) + } + let outcome: ReturnType + try { + outcome = authorObject( + 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, authorObjectTool.name)), + } + return { + content: [{ type: 'text' as const, text: JSON.stringify(payload) }], + structuredContent: payload, + } + }, + ) +} diff --git a/packages/mcp/src/tools/index.ts b/packages/mcp/src/tools/index.ts index bb3b49873f..1c8304cb76 100644 --- a/packages/mcp/src/tools/index.ts +++ b/packages/mcp/src/tools/index.ts @@ -1,6 +1,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { SceneOperations } from '../operations' import { registerApplyPatch } from './apply-patch' +import { type GeometryScriptHost, registerAuthorObject } from './author-object' import { registerCheckCollisions } from './check-collisions' import { registerConstructionTools } from './construction-tools' import { registerCreateLevel } from './create-level' @@ -38,11 +39,16 @@ 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) + registerAuthorObject(server, operations, geometryScripts) registerMeasure(server, operations) registerConstructionTools(server, operations) registerRoomTools(server, operations) diff --git a/packages/mcp/src/tools/read-tool-annotations.test.ts b/packages/mcp/src/tools/read-tool-annotations.test.ts index 08edfcc006..a2c3fbae8f 100644 --- a/packages/mcp/src/tools/read-tool-annotations.test.ts +++ b/packages/mcp/src/tools/read-tool-annotations.test.ts @@ -85,6 +85,7 @@ const TOOL_POLICIES = [ }, tools: [ 'apply_patch', + 'author_object', 'create_from_template', 'create_house_from_brief', 'create_stair_between_levels', @@ -156,7 +157,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(66) expect([...byName.keys()].toSorted()).toEqual(EXPECTED_TOOL_NAMES) for (const policy of TOOL_POLICIES) { diff --git a/packages/mcp/src/tools/shared-tools.ts b/packages/mcp/src/tools/shared-tools.ts index fc5bcc3ef5..0f2b072f15 100644 --- a/packages/mcp/src/tools/shared-tools.ts +++ b/packages/mcp/src/tools/shared-tools.ts @@ -148,7 +148,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/plugin-evals/tool-annotation-justifications.json b/plugin-evals/tool-annotation-justifications.json index d0faf79ce4..03a3033c00 100644 --- a/plugin-evals/tool-annotation-justifications.json +++ b/plugin-evals/tool-annotation-justifications.json @@ -71,6 +71,19 @@ "openWorldHint": "It operates on the connected Pascal scene or project service without accessing the public internet." } }, + { + "name": "author_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": "check_collisions", "annotations": { diff --git a/scripts/openai-tool-annotation-policy.test.ts b/scripts/openai-tool-annotation-policy.test.ts index 9ad4158519..31da2de823 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 66-tool inventory', ) }) diff --git a/scripts/openai-tool-annotation-policy.ts b/scripts/openai-tool-annotation-policy.ts index 3ec969f161..6aae67e5f6 100644 --- a/scripts/openai-tool-annotation-policy.ts +++ b/scripts/openai-tool-annotation-policy.ts @@ -19,6 +19,7 @@ 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), + author_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), From dcfe86d35f65d2930ef363a76a56667e3220a8c9 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 2 Oct 2026 09:11:10 -0400 Subject: [PATCH 04/28] feat(placement): objects land on an authored object's real surfaces - core: geometrySurfaceAt / geometryRestingHeight. An object's main surface (non-roof, at least half its footprint) wins under the point, so a bench anywhere on a porch lands on the landing, never the beam or the canopy; roof-typed parts never receive. author_object sets asset.surface from it. - compiler: one surface outline per mesh and height (a cross-mesh hull merged a beam and its returns into one surface). - mcp place_item: can target an item; level coordinates map into the host frame; on an authored host it lands on the surface below, reports restingOn. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QUGvAXaawxPa5rnLSE53iZ --- .../src/agent-operations/author-object.ts | 3 + packages/core/src/index.ts | 1 + packages/core/src/lib/geometry-surfaces.ts | 74 +++++++++++++++++++ packages/geometry-script/src/compile.ts | 6 +- packages/mcp/src/tools/place-item.ts | 37 +++++++++- 5 files changed, 116 insertions(+), 5 deletions(-) create mode 100644 packages/core/src/lib/geometry-surfaces.ts diff --git a/packages/core/src/agent-operations/author-object.ts b/packages/core/src/agent-operations/author-object.ts index 0ffae05aa2..7c82118307 100644 --- a/packages/core/src/agent-operations/author-object.ts +++ b/packages/core/src/agent-operations/author-object.ts @@ -1,5 +1,6 @@ import { refuse } from '../agent-tools/refusal' import { artifactUrl } from '../lib/artifact-store' +import { geometryRestingHeight } from '../lib/geometry-surfaces' import { type AnyNode, type CompiledGeometryScript, @@ -52,6 +53,7 @@ function scriptAsset( ): ItemNode['asset'] { const { min, max } = compiled.manifest.bounds const lights = compiled.manifest.lights + const restingHeight = geometryRestingHeight(compiled.manifest) return { id: `script_${compiled.sha256.slice(0, 16)}`, category: input.category ?? previous?.category ?? 'object', @@ -61,6 +63,7 @@ function scriptAsset( 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], diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index aef76ce775..06e4750006 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -170,6 +170,7 @@ export { withFloorStepOverride, withoutFloorStepOverrideKeys, } from './lib/floor-step-finish' +export { geometryRestingHeight, geometrySurfaceAt } from './lib/geometry-surfaces' export { type ExposedInterval, exposedIntervals, diff --git a/packages/core/src/lib/geometry-surfaces.ts b/packages/core/src/lib/geometry-surfaces.ts new file mode 100644 index 0000000000..e100f268d8 --- /dev/null +++ b/packages/core/src/lib/geometry-surfaces.ts @@ -0,0 +1,74 @@ +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 +} diff --git a/packages/geometry-script/src/compile.ts b/packages/geometry-script/src/compile.ts index 9bcd43d228..c0830c62f4 100644 --- a/packages/geometry-script/src/compile.ts +++ b/packages/geometry-script/src/compile.ts @@ -318,7 +318,7 @@ function convexHull(points: [number, number][]): [number, number][] { /** * 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 part and height. + * geometry without a raycast. Outlines are convex hulls per mesh and height. */ function analyseGeometry(root: THREE.Object3D, parts: GeometryArtifactManifest['parts']) { const partBounds = new Map() @@ -356,7 +356,9 @@ function analyseGeometry(root: THREE.Object3D, parts: GeometryArtifactManifest[' const area = normal.length() / 2 if (area === 0 || normal.y / (2 * area) < SURFACE_MIN_NORMAL_Y) continue const y = Math.round(((a.y + b.y + c.y) / 3) * 100) / 100 - const key = `${part ?? ''}|${y}` + // 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]) diff --git a/packages/mcp/src/tools/place-item.ts b/packages/mcp/src/tools/place-item.ts index 90d53f7515..60dab3a609 100644 --- a/packages/mcp/src/tools/place-item.ts +++ b/packages/mcp/src/tools/place-item.ts @@ -1,4 +1,5 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { geometrySurfaceAt } 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 +22,7 @@ export const placeItemInput = { export const placeItemOutput = { itemId: z.string(), status: z.string().optional(), + restingOn: z.string().optional(), ...liveSyncOutput, } @@ -30,7 +32,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 author_object it lands on the real surface below the point, such as a porch landing, unless position[1] is set above 0). Do not target the site node directly.', inputSchema: placeItemInput, outputSchema: placeItemOutput, annotations: ADDITIVE_TOOL_ANNOTATIONS, @@ -46,11 +48,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,6 +96,33 @@ export function registerPlaceItem(server: McpServer, bridge: SceneOperations): v }) } + let restingOn: string | 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 surface = target.source ? geometrySurfaceAt(target.source.manifest, lx, lz) : null + restingOn = requestedPosition[1] > 0 ? undefined : surface?.part + const ly = + requestedPosition[1] > 0 + ? requestedPosition[1] - hy + : surface + ? surface.y * target.scale[1] + : (target.asset.surface?.height ?? target.asset.dimensions[1]) * target.scale[1] + itemPosition = [lx * target.scale[0], ly, lz * target.scale[2]] + } + const item = ItemNode.parse({ position: itemPosition, rotation: [0, rotation ?? 0, 0], @@ -104,6 +134,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 { From a74515b9e1b1bd692e47a711cec08a0030ea2a0f Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 2 Oct 2026 09:13:27 -0400 Subject: [PATCH 05/28] perf(geometry-script): one mesh per part and material; lit items still batch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - compiler merges each part's meshes by material (the porch: ~80 meshes → 16), part extras and helpers kept. - itemBatchable excludes only animated items: light effects drive separate light objects, so a lit porch or lamp batches. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QUGvAXaawxPa5rnLSE53iZ --- packages/geometry-script/src/compile.ts | 46 +++++++++++++++++++ .../nodes/src/shared/node-batch/batchable.ts | 13 ++++-- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/packages/geometry-script/src/compile.ts b/packages/geometry-script/src/compile.ts index c0830c62f4..4211ca2997 100644 --- a/packages/geometry-script/src/compile.ts +++ b/packages/geometry-script/src/compile.ts @@ -383,6 +383,51 @@ function analyseGeometry(root: THREE.Object3D, parts: GeometryArtifactManifest[' })) } +/** + * 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) { + 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) + 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) + } +} + // GLTFExporter writes binaries through FileReader, which Bun and Node lack. function ensureFileReader() { const g = globalThis as { FileReader?: unknown } @@ -484,6 +529,7 @@ export async function compileGeometryScript( } const surfaces = analyseGeometry(root, conventions.parts) + mergeByPartAndMaterial(root) const bounds = visibleBounds(root) const glb = await exportGlb(root) return { 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. From 9a391e4aaa244e6bce59dd448f9494c1181b2739 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 2 Oct 2026 09:24:45 -0400 Subject: [PATCH 06/28] feat(geometry-script): authored objects animate with their own three.js clips - compiler exports the module's group.animations (tracks rebound to uuids, so part: targets survive; animated subtrees are not merged), manifest lists clips. - author_object: a clip named open becomes the item's open/close toggle (close plays 'close' or 'open' reversed), 'loop' runs throughout; other clips stay in the artifact for later controls. - item renderer: open-close mode plays open once and holds, reverses on close; the bake keeps the opening as a once clip. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QUGvAXaawxPa5rnLSE53iZ --- .../src/agent-operations/author-object.ts | 57 ++++++++---- .../core/src/agent-tools/author-object.ts | 3 +- packages/core/src/schema/geometry-source.ts | 2 + packages/core/src/schema/nodes/item.ts | 4 + packages/geometry-script/src/compile.ts | 82 +++++++++++++++-- packages/nodes/src/item/renderer.tsx | 90 ++++++++++++++++++- 6 files changed, 214 insertions(+), 24 deletions(-) diff --git a/packages/core/src/agent-operations/author-object.ts b/packages/core/src/agent-operations/author-object.ts index 7c82118307..ac3db3661a 100644 --- a/packages/core/src/agent-operations/author-object.ts +++ b/packages/core/src/agent-operations/author-object.ts @@ -46,13 +46,51 @@ const HOSTS: Record = { ceiling: ['ceiling'], } +/** + * 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), and a `loop` clip that runs throughout. Other clips stay + * in the artifact for later controls. + */ +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') || clip('loop')) { + if (clip('open')) controls.push({ kind: 'toggle', label: 'Open', default: false }) + effects.push({ + kind: 'animation', + mode: 'open-close', + clips: { + on: clip('open') ? 'open' : undefined, + off: clip('close') ? 'close' : undefined, + loop: clip('loop') ? 'loop' : undefined, + }, + }) + } + return effects.length > 0 ? { controls, effects } : undefined +} + function scriptAsset( compiled: CompiledGeometryScript, input: AuthorObjectInput, previous: ItemNode['asset'] | undefined, ): ItemNode['asset'] { const { min, max } = compiled.manifest.bounds - const lights = compiled.manifest.lights const restingHeight = geometryRestingHeight(compiled.manifest) return { id: `script_${compiled.sha256.slice(0, 16)}`, @@ -67,19 +105,7 @@ function scriptAsset( offset: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1], - interactive: - lights.length > 0 - ? { - controls: [{ kind: 'toggle', label: 'Lights', default: true }], - effects: lights.map((light) => ({ - kind: 'light' as const, - color: light.color, - intensityRange: [0, light.intensity] as [number, number], - distance: light.distance, - offset: light.position, - })), - } - : undefined, + interactive: scriptInteractive(compiled.manifest), } } @@ -95,7 +121,7 @@ const scriptSource = (compiled: CompiledGeometryScript, code: string) => ({ const round = (value: number) => Math.round(value * 1000) / 1000 function summary(node: ItemNode, compiled: CompiledGeometryScript, orphanedSlots: string[]) { - const { bounds, parts, slots, lights, params, triangles, cutout } = compiled.manifest + const { bounds, parts, slots, lights, params, triangles, cutout, animations } = compiled.manifest return { nodeId: node.id, mount: compiled.mount, @@ -103,6 +129,7 @@ function summary(node: ItemNode, compiled: CompiledGeometryScript, orphanedSlots 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, diff --git a/packages/core/src/agent-tools/author-object.ts b/packages/core/src/agent-tools/author-object.ts index 0f1c6afc11..170ed8ce7f 100644 --- a/packages/core/src/agent-tools/author-object.ts +++ b/packages/core/src/agent-tools/author-object.ts @@ -18,11 +18,12 @@ Conventions (they make the object work in Pascal; follow them): - 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 .. 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. Write as many clips as the object needs. - Wall opening: a box mesh named cutout (wall mount) is cut out of the host wall and never renders. - 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; identity, placement and paint are kept. The result lists the size, parts, slots, lights and params.` +Edit: pass nodeId with new code and/or params; identity, placement and paint are kept. The result lists the size, parts, slots, lights, animations and params.` export const authorObjectTool = { name: 'author_object', diff --git a/packages/core/src/schema/geometry-source.ts b/packages/core/src/schema/geometry-source.ts index 28017ff945..4881c8cf60 100644 --- a/packages/core/src/schema/geometry-source.ts +++ b/packages/core/src/schema/geometry-source.ts @@ -68,6 +68,8 @@ export const GeometryArtifactManifest = z.object({ }), ) .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(), diff --git a/packages/core/src/schema/nodes/item.ts b/packages/core/src/schema/nodes/item.ts index 7b08fe3060..bd601240e3 100644 --- a/packages/core/src/schema/nodes/item.ts +++ b/packages/core/src/schema/nodes/item.ts @@ -43,6 +43,10 @@ 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(), clips: z.object({ on: z.string().optional(), off: z.string().optional(), diff --git a/packages/geometry-script/src/compile.ts b/packages/geometry-script/src/compile.ts index 4211ca2997..95e90d00ca 100644 --- a/packages/geometry-script/src/compile.ts +++ b/packages/geometry-script/src/compile.ts @@ -389,7 +389,7 @@ function analyseGeometry(root: THREE.Object3D, parts: GeometryArtifactManifest[' * groups keep their names and extras; helpers (cutout, collider) and * multi-material meshes stay as authored. */ -function mergeByPartAndMaterial(root: THREE.Object3D) { +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 @@ -405,6 +405,10 @@ function mergeByPartAndMaterial(root: THREE.Object3D) { 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: [] } @@ -428,6 +432,66 @@ function mergeByPartAndMaterial(root: THREE.Object3D) { } } +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 } +} + // GLTFExporter writes binaries through FileReader, which Bun and Node lack. function ensureFileReader() { const g = globalThis as { FileReader?: unknown } @@ -450,10 +514,13 @@ function ensureFileReader() { } } -async function exportGlb(root: THREE.Object3D): Promise { +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 }) + 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 @@ -516,6 +583,7 @@ export async function compileGeometryScript( built.position.sub(originFor(box, mount)) root.updateWorldMatrix(true, true) + const { clips, animated } = readAnimations(built, root) const conventions = readConventions(root) if (conventions.triangles > GEOMETRY_SCRIPT_LIMITS.triangles) { throw new Error( @@ -529,9 +597,9 @@ export async function compileGeometryScript( } const surfaces = analyseGeometry(root, conventions.parts) - mergeByPartAndMaterial(root) + mergeByPartAndMaterial(root, animated) const bounds = visibleBounds(root) - const glb = await exportGlb(root) + const glb = await exportGlb(root, clips) return { glb, sha256: await digest(glb), @@ -545,6 +613,10 @@ export async function compileGeometryScript( 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/nodes/src/item/renderer.tsx b/packages/nodes/src/item/renderer.tsx index b3e7e90a1e..e406683c2f 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' @@ -702,10 +702,12 @@ const LoadedModelRenderer = ({ // find it without this registry. The bake retargets it onto the baked subtree. useEffect(() => { if (!animEffect) return + // An open-close object bakes its opening, played once; its loop otherwise. + const openClose = animEffect.mode === 'open-close' 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 }) + itemClipRegistry.set(node.id, { clip, loop: !(openClose && clipName === animEffect.clips.on) }) return () => { itemClipRegistry.delete(node.id) } @@ -726,7 +728,16 @@ const LoadedModelRenderer = ({ {...handlers} /> - {animations.length > 0 && ( + {animations.length > 0 && animEffect?.mode === 'open-close' && ( + + )} + {animations.length > 0 && animEffect?.mode !== 'open-close' && ( +}) => { + const { actions } = useAnimations(animations, rootRef) + const toggles = interactive.controls.flatMap((control, index) => + control.kind === 'toggle' ? [index] : [], + ) + const openToggle = interactive.effects.some((effect) => effect.kind === 'light') + ? toggles[1] + : toggles[0] + const isOpen = useInteractive((s) => + openToggle === undefined ? false : Boolean(s.items[nodeId]?.controlValues[openToggle]), + ) + + useEffect(() => { + const loop = animEffect.clips.loop ? actions[animEffect.clips.loop] : undefined + loop?.play() + return () => { + loop?.stop() + } + }, [actions, animEffect.clips.loop]) + + const mounted = useRef(false) + useEffect(() => { + const open = animEffect.clips.on ? actions[animEffect.clips.on] : undefined + const close = animEffect.clips.off ? actions[animEffect.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, animEffect.clips.on, animEffect.clips.off, isOpen]) + + return null +} + const ItemLightRegistrar = ({ nodeId, effect, From 6b38e8501edbd1a20e0462688fb296a07efb66e6 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 2 Oct 2026 09:41:27 -0400 Subject: [PATCH 07/28] feat(item): every authored clip gets a control and reaches the GLB export - An animation effect can name its toggle (control); authored objects get a play toggle per clip besides open/close/loop, labelled with the clip name (a music box's 'Princess twirl'). E plays them all. - itemClipRegistry holds every clip of a node; the GLB export emits each as ': ' (catalog items keep ': loop'). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QUGvAXaawxPa5rnLSE53iZ --- .../src/agent-operations/author-object.ts | 27 ++-- .../core/src/agent-tools/author-object.ts | 2 +- .../scene-registry/item-clip-registry.ts | 9 +- packages/core/src/schema/nodes/item.ts | 4 + packages/editor/src/lib/glb-export.ts | 65 +++++----- packages/nodes/src/item/renderer.tsx | 122 ++++++++++++------ .../src/shared/node-batch/node-batch.test.ts | 2 +- 7 files changed, 148 insertions(+), 83 deletions(-) diff --git a/packages/core/src/agent-operations/author-object.ts b/packages/core/src/agent-operations/author-object.ts index ac3db3661a..885780df92 100644 --- a/packages/core/src/agent-operations/author-object.ts +++ b/packages/core/src/agent-operations/author-object.ts @@ -49,8 +49,8 @@ const HOSTS: Record = { /** * 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), and a `loop` clip that runs throughout. Other clips stay - * in the artifact for later controls. + * `open` reversed), a `loop` clip that runs throughout, and a play toggle per + * other clip, labelled with its name. */ function scriptInteractive( manifest: CompiledGeometryScript['manifest'], @@ -70,17 +70,26 @@ function scriptInteractive( } } const clip = (name: string) => manifest.animations.some((animation) => animation.name === name) - if (clip('open') || clip('loop')) { - if (clip('open')) controls.push({ kind: 'toggle', label: 'Open', default: false }) + if (clip('open')) { effects.push({ kind: 'animation', mode: 'open-close', - clips: { - on: clip('open') ? 'open' : undefined, - off: clip('close') ? 'close' : undefined, - loop: clip('loop') ? 'loop' : undefined, - }, + 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/agent-tools/author-object.ts b/packages/core/src/agent-tools/author-object.ts index 170ed8ce7f..282e009062 100644 --- a/packages/core/src/agent-tools/author-object.ts +++ b/packages/core/src/agent-tools/author-object.ts @@ -18,7 +18,7 @@ Conventions (they make the object work in Pascal; follow them): - 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 .. 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. Write as many clips as the object needs. +- Motion: put THREE.AnimationClips on the returned group's .animations, as in any three.js project; tracks target . or .. 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 box mesh named cutout (wall mount) is cut out of the host wall and never renders. - 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. 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/schema/nodes/item.ts b/packages/core/src/schema/nodes/item.ts index bd601240e3..eb3c06904c 100644 --- a/packages/core/src/schema/nodes/item.ts +++ b/packages/core/src/schema/nodes/item.ts @@ -47,6 +47,10 @@ const animationEffectSchema = z.object({ // 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(), diff --git a/packages/editor/src/lib/glb-export.ts b/packages/editor/src/lib/glb-export.ts index cd63ee7d6f..c6bf5224e9 100644 --- a/packages/editor/src/lib/glb-export.ts +++ b/packages/editor/src/lib/glb-export.ts @@ -1475,44 +1475,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/nodes/src/item/renderer.tsx b/packages/nodes/src/item/renderer.tsx index e406683c2f..1d0e2b6ed1 100644 --- a/packages/nodes/src/item/renderer.tsx +++ b/packages/nodes/src/item/renderer.tsx @@ -697,21 +697,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 - // An open-close object bakes its opening, played once; its loop otherwise. - const openClose = animEffect.mode === 'open-close' - 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: !(openClose && clipName === animEffect.clips.on) }) + 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. @@ -728,16 +732,15 @@ const LoadedModelRenderer = ({ {...handlers} /> - {animations.length > 0 && animEffect?.mode === 'open-close' && ( - 0 && scripted && interactive && ( + )} - {animations.length > 0 && animEffect?.mode !== 'open-close' && ( + {animations.length > 0 && !scripted && ( + useInteractive((s) => + control === undefined ? true : Boolean(s.items[nodeId]?.controlValues[control]), + ) + /** - * An authored object's motion: `on` (its `open` clip) plays once and holds; - * closing plays `off` or `on` reversed; `loop` runs throughout. Driven by the - * mechanism toggle, the one that is not the light switch. + * 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 OpenCloseAnimation = ({ +const ScriptedAnimations = ({ nodeId, - animEffect, interactive, animations, rootRef, }: { nodeId: AnyNodeId - animEffect: AnimationEffect interactive: Interactive animations: AnimationClip[] rootRef: RefObject }) => { const { actions } = useAnimations(animations, rootRef) - const toggles = interactive.controls.flatMap((control, index) => - control.kind === 'toggle' ? [index] : [], + const effects = interactive.effects.filter( + (effect): effect is AnimationEffect => effect.kind === 'animation', ) - const openToggle = interactive.effects.some((effect) => effect.kind === 'light') - ? toggles[1] - : toggles[0] - const isOpen = useInteractive((s) => - openToggle === undefined ? false : Boolean(s.items[nodeId]?.controlValues[openToggle]), + 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 loop = animEffect.clips.loop ? actions[animEffect.clips.loop] : undefined - loop?.play() - return () => { - loop?.stop() + 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, animEffect.clips.loop]) + }, [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 = animEffect.clips.on ? actions[animEffect.clips.on] : undefined - const close = animEffect.clips.off ? actions[animEffect.clips.off] : undefined + 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 @@ -901,8 +948,7 @@ const OpenCloseAnimation = ({ open.paused = false open.timeScale = -1 open.play() - }, [actions, animEffect.clips.on, animEffect.clips.off, isOpen]) - + }, [actions, effect.clips.on, effect.clips.off, isOpen]) return null } 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') }) From c4457815e4e488063f8a889aeba71e4ce371315a Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 2 Oct 2026 09:46:38 -0400 Subject: [PATCH 08/28] fix(geometry-script): sample clips to transform tracks so any motion survives glTF GLTFExporter keeps only position/quaternion/scale tracks and silently dropped an Euler rotation[y] track, leaving an authored music box with an empty clip. Each clip now plays once in a mixer and is sampled (30 fps) back into position/quaternion/scale tracks; a clip that moves nothing is refused with the reason. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QUGvAXaawxPa5rnLSE53iZ --- .../core/src/agent-tools/author-object.ts | 2 +- packages/geometry-script/src/compile.ts | 78 ++++++++++++++++++- 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/packages/core/src/agent-tools/author-object.ts b/packages/core/src/agent-tools/author-object.ts index 282e009062..570cd0fc25 100644 --- a/packages/core/src/agent-tools/author-object.ts +++ b/packages/core/src/agent-tools/author-object.ts @@ -18,7 +18,7 @@ Conventions (they make the object work in Pascal; follow them): - 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 .. 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. +- 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 box mesh named cutout (wall mount) is cut out of the host wall and never renders. - 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. diff --git a/packages/geometry-script/src/compile.ts b/packages/geometry-script/src/compile.ts index 95e90d00ca..17457c3e64 100644 --- a/packages/geometry-script/src/compile.ts +++ b/packages/geometry-script/src/compile.ts @@ -492,6 +492,80 @@ function readAnimations(built: THREE.Object3D, root: THREE.Object3D) { 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) + 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 } @@ -583,7 +657,9 @@ export async function compileGeometryScript( built.position.sub(originFor(box, mount)) root.updateWorldMatrix(true, true) - const { clips, animated } = readAnimations(built, root) + 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( From df88378d903556b2303af838d6e9039a032098a0 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 2 Oct 2026 09:59:45 -0400 Subject: [PATCH 09/28] feat(item): a Parameters section for authored objects The params a script declares become inspector controls (sliders, toggles, option segments); a change re-runs the script with the new values as one undo step, stale rebuilds dropped. Duplicate keeps the script and paint. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QUGvAXaawxPa5rnLSE53iZ --- packages/editor/src/index.tsx | 5 +- .../editor/src/lib/geometry-script/author.ts | 31 +++++++ packages/nodes/src/item/authored-params.tsx | 91 +++++++++++++++++++ packages/nodes/src/item/panel.tsx | 5 + 4 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 packages/nodes/src/item/authored-params.tsx diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 4fd01f6a09..d2b5f8736d 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -577,7 +577,10 @@ export { commitFreshPlacementSubtree, createFreshPlacementSubtree, } from './lib/fresh-planar-placement' -export { compileAndStoreGeometryScript } from './lib/geometry-script/author' +export { + compileAndStoreGeometryScript, + rebuildAuthoredObject, +} from './lib/geometry-script/author' export { compileGeometryScriptInWorker } from './lib/geometry-script/client' export { exportSceneToGlb, type GlbExportOptions } from './lib/glb-export' export { diff --git a/packages/editor/src/lib/geometry-script/author.ts b/packages/editor/src/lib/geometry-script/author.ts index 4ed6e812fe..b22a8a7c89 100644 --- a/packages/editor/src/lib/geometry-script/author.ts +++ b/packages/editor/src/lib/geometry-script/author.ts @@ -1,8 +1,11 @@ import { + type AnyNodeId, type CompiledGeometryScript, type GeometryScriptParamValue, getArtifactStore, + useScene, } from '@pascal-app/core' +import { authorObject } from '@pascal-app/core/agent-operations' import { compileGeometryScriptInWorker } from './client' /** @@ -18,3 +21,31 @@ export async function compileAndStoreGeometryScript(input: { await getArtifactStore().put(compiled.sha256, glb, 'model/gltf-binary') return compiled } + +const rebuildGeneration = new Map() + +/** + * Re-runs an authored object's script with new param values, the inspector's + * 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, +): Promise { + const node = useScene.getState().nodes[nodeId as AnyNodeId] + if (node?.type !== 'item' || !node.source) throw new Error(`${nodeId} is not an authored object`) + const generation = (rebuildGeneration.get(nodeId) ?? 0) + 1 + rebuildGeneration.set(nodeId, generation) + const { code } = node.source + const compiled = await compileAndStoreGeometryScript({ code, params }) + if (rebuildGeneration.get(nodeId) !== generation) return + const { changes } = authorObject( + useScene.getState().nodes, + { code, params, nodeId, compiled }, + { activeLevelId: null }, + ) + for (const { id, data } of changes?.update ?? []) { + useScene.getState().updateNode(id as AnyNodeId, data) + } +} diff --git a/packages/nodes/src/item/authored-params.tsx b/packages/nodes/src/item/authored-params.tsx new file mode 100644 index 0000000000..908cd00243 --- /dev/null +++ b/packages/nodes/src/item/authored-params.tsx @@ -0,0 +1,91 @@ +'use client' + +import type { GeometryScriptParamSpec, GeometryScriptParamValue, ItemNode } 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: ItemNode }) { + 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) => + setError(reason instanceof Error ? reason.message : String(reason)), + ) + .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/panel.tsx b/packages/nodes/src/item/panel.tsx index cffbe56435..6e0f2ad670 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} > + + Date: Fri, 2 Oct 2026 10:08:58 -0400 Subject: [PATCH 10/28] fix(authored objects): no parts cap; the panel shows a plain error The 64-part refusal blocked a 245-part porch and surfaced an AI-facing rule to the person. No cap for now; the parts guidance stays in the tool description. A failed rebuild says so plainly and logs the reason. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QUGvAXaawxPa5rnLSE53iZ --- packages/core/src/agent-operations/author-object.ts | 10 ---------- packages/nodes/src/item/authored-params.tsx | 7 ++++--- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/packages/core/src/agent-operations/author-object.ts b/packages/core/src/agent-operations/author-object.ts index 885780df92..35cc88922d 100644 --- a/packages/core/src/agent-operations/author-object.ts +++ b/packages/core/src/agent-operations/author-object.ts @@ -14,9 +14,6 @@ import type { AgentOperation } from './types' type Vec3 = [number, number, number] -/** How many named parts one object may carry; past this it is several objects. */ -export const AUTHORED_OBJECT_MAX_PARTS = 64 - export type AuthorObjectInput = { code: string params?: Record @@ -160,13 +157,6 @@ function summary(node: ItemNode, compiled: CompiledGeometryScript, orphanedSlots */ export const authorObject: AgentOperation = (nodes, input, context) => { const { compiled } = input - if (compiled.manifest.parts.length > AUTHORED_OBJECT_MAX_PARTS) { - refuse( - 'too_many_parts', - `The object has ${compiled.manifest.parts.length} parts; at most ${AUTHORED_OBJECT_MAX_PARTS}. Group detail into fewer parts, or build separate objects for things that are separate.`, - { parts: compiled.manifest.parts.length }, - ) - } const rotation: Vec3 | undefined = input.rotation === undefined ? undefined : [0, (input.rotation * Math.PI) / 180, 0] diff --git a/packages/nodes/src/item/authored-params.tsx b/packages/nodes/src/item/authored-params.tsx index 908cd00243..306969b97f 100644 --- a/packages/nodes/src/item/authored-params.tsx +++ b/packages/nodes/src/item/authored-params.tsx @@ -26,9 +26,10 @@ export function AuthoredParams({ node }: { node: ItemNode }) { setBusy(true) setError(null) rebuildAuthoredObject(node.id, { ...source.params, [id]: value }) - .catch((reason: unknown) => - setError(reason instanceof Error ? reason.message : String(reason)), - ) + .catch((reason: unknown) => { + console.error('[authored object] rebuild failed', reason) + setError("Couldn't rebuild with these values.") + }) .finally(() => { setBusy(false) setDrafts((current) => { From ec62d1c08912b50ad65e1bd261ff5e34dd55be8b Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 2 Oct 2026 10:52:28 -0400 Subject: [PATCH 11/28] feat(hosting): ceiling items hang from an authored object's undersides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Manual: authored items are face hosts — hovering a downward face (a vault plane, a soffit, a beam) with a ceiling item hangs it there, upright, as the object's child; catalog items keep their top-surface behaviour. - AI: the compiler records undersides as plane + outline (sloped ones included); place_item hangs a ceiling item from the lowest underside above the point. A hanging item always takes the underside; a resting one only honours an explicit height above 0 (models fill 0 in by default). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QUGvAXaawxPa5rnLSE53iZ --- packages/core/src/index.ts | 6 +- packages/core/src/lib/geometry-surfaces.ts | 20 +++++ packages/core/src/schema/geometry-source.ts | 14 +++ .../tools/item/use-placement-coordinator.tsx | 27 ++++++ packages/geometry-script/src/compile.ts | 65 +++++++++++--- packages/mcp/src/tools/place-item.ts | 31 ++++--- packages/nodes/src/item/authored-face-host.ts | 87 +++++++++++++++++++ packages/nodes/src/item/definition.ts | 3 + 8 files changed, 229 insertions(+), 24 deletions(-) create mode 100644 packages/nodes/src/item/authored-face-host.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 06e4750006..26d1c944d9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -170,7 +170,11 @@ export { withFloorStepOverride, withoutFloorStepOverrideKeys, } from './lib/floor-step-finish' -export { geometryRestingHeight, geometrySurfaceAt } from './lib/geometry-surfaces' +export { + geometryRestingHeight, + geometrySurfaceAt, + geometryUndersideAt, +} from './lib/geometry-surfaces' export { type ExposedInterval, exposedIntervals, diff --git a/packages/core/src/lib/geometry-surfaces.ts b/packages/core/src/lib/geometry-surfaces.ts index e100f268d8..8a1286a775 100644 --- a/packages/core/src/lib/geometry-surfaces.ts +++ b/packages/core/src/lib/geometry-surfaces.ts @@ -72,3 +72,23 @@ export function geometryRestingHeight( ): 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 } | null { + let best: { part?: string; y: 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 } + } + return best +} diff --git a/packages/core/src/schema/geometry-source.ts b/packages/core/src/schema/geometry-source.ts index 4881c8cf60..f0020f864b 100644 --- a/packages/core/src/schema/geometry-source.ts +++ b/packages/core/src/schema/geometry-source.ts @@ -68,6 +68,20 @@ export const GeometryArtifactManifest = z.object({ }), ) .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), 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/geometry-script/src/compile.ts b/packages/geometry-script/src/compile.ts index 17457c3e64..6785159661 100644 --- a/packages/geometry-script/src/compile.ts +++ b/packages/geometry-script/src/compile.ts @@ -286,6 +286,8 @@ function readConventions(root: THREE.Object3D) { } 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 @@ -326,6 +328,10 @@ function analyseGeometry(root: THREE.Object3D, parts: GeometryArtifactManifest[' 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() @@ -354,7 +360,27 @@ function analyseGeometry(root: THREE.Object3D, parts: GeometryArtifactManifest[' 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 || normal.y / (2 * area) < SURFACE_MIN_NORMAL_Y) continue + 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. @@ -370,17 +396,29 @@ function analyseGeometry(root: THREE.Object3D, parts: GeometryArtifactManifest[' const bounds = partBounds.get(part.id) if (bounds && !bounds.isEmpty()) part.bounds = { min: vec(bounds.min), max: vec(bounds.max) } } - return [...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: convexHull(surface.points).map( - ([x, z]) => [Math.round(x * 1000) / 1000, Math.round(z * 1000) / 1000] as [number, number], - ), - })) + 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], + })), + } } /** @@ -672,7 +710,7 @@ export async function compileGeometryScript( ) } - const surfaces = analyseGeometry(root, conventions.parts) + const { surfaces, undersides } = analyseGeometry(root, conventions.parts) mergeByPartAndMaterial(root, animated) const bounds = visibleBounds(root) const glb = await exportGlb(root, clips) @@ -686,6 +724,7 @@ export async function compileGeometryScript( params: specs, parts: conventions.parts, surfaces, + undersides, slots: conventions.slots, anchors: conventions.anchors, lights: conventions.lights, diff --git a/packages/mcp/src/tools/place-item.ts b/packages/mcp/src/tools/place-item.ts index 60dab3a609..d026f77d14 100644 --- a/packages/mcp/src/tools/place-item.ts +++ b/packages/mcp/src/tools/place-item.ts @@ -1,5 +1,5 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import { geometrySurfaceAt } from '@pascal-app/core' +import { geometrySurfaceAt, geometryUndersideAt } 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' @@ -32,7 +32,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, a ceiling for ceiling-attached items, or an item to rest on it (position in level coordinates; on an object built with author_object it lands on the real surface below the point, such as a porch landing, unless position[1] is set above 0). 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 author_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, @@ -112,14 +112,25 @@ export function registerPlaceItem(server: McpServer, bridge: SceneOperations): v 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 surface = target.source ? geometrySurfaceAt(target.source.manifest, lx, lz) : null - restingOn = requestedPosition[1] > 0 ? undefined : surface?.part - const ly = - requestedPosition[1] > 0 - ? requestedPosition[1] - hy - : surface - ? surface.y * target.scale[1] - : (target.asset.surface?.height ?? target.asset.dimensions[1]) * target.scale[1] + 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 drop = hanging + ? 'recessed' in baseAsset && baseAsset.recessed + ? 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]] } 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..82a0a08098 --- /dev/null +++ b/packages/nodes/src/item/authored-face-host.ts @@ -0,0 +1,87 @@ +import { + type FaceHostCapability, + type FaceHostPlacementArgs, + type ItemNode, + 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 } +} + +/** + * 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. The + * item hangs upright from the point and 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 drop = args.asset.recessed ? 0.02 : args.rawDimensions[1] + const position: [number, number, number] = [hit.point.x, hit.point.y - drop, hit.point.z] + const yaw = args.draftItem?.rotation[1] ?? 0 + const rotation: [number, number, number] = [0, yaw, 0] + const cursor = hit.world.clone() + cursor.y -= drop + 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/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) => { From d630fa302a8b2c8e93533df3351c1b3030513d49 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 2 Oct 2026 11:20:45 -0400 Subject: [PATCH 12/28] feat(item): X/Z rotation in the panel; flush fixtures tilt with sloped undersides - Item panel: X and Z rotation sliders around Y (aim a spot, lean a frame). - Flush ceiling fixtures (recessed flag, or 15 cm tall or less: catalog data often lacks the flag) seat along an authored underside and tilt with its slope; pendants and fans hang plumb. Same rule for manual placement, place_on_surface and MCP place_item (core flushMountRotation / mountsFlush). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QUGvAXaawxPa5rnLSE53iZ --- packages/core/src/index.ts | 2 + packages/core/src/lib/geometry-surfaces.ts | 51 +++++++++++++++++-- packages/mcp/src/tools/place-item.ts | 23 ++++++--- packages/nodes/src/item/authored-face-host.ts | 26 +++++++--- packages/nodes/src/item/panel.tsx | 37 ++++++++++++++ 5 files changed, 121 insertions(+), 18 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 26d1c944d9..672d356bc3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -171,9 +171,11 @@ export { withoutFloorStepOverrideKeys, } from './lib/floor-step-finish' export { + flushMountRotation, geometryRestingHeight, geometrySurfaceAt, geometryUndersideAt, + mountsFlush, } from './lib/geometry-surfaces' export { type ExposedInterval, diff --git a/packages/core/src/lib/geometry-surfaces.ts b/packages/core/src/lib/geometry-surfaces.ts index 8a1286a775..c99726e4c9 100644 --- a/packages/core/src/lib/geometry-surfaces.ts +++ b/packages/core/src/lib/geometry-surfaces.ts @@ -82,13 +82,58 @@ export function geometryUndersideAt( manifest: Pick, x: number, z: number, -): { part?: string; y: number } | null { - let best: { part?: string; y: number } | null = null +): { 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 } + 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 +} diff --git a/packages/mcp/src/tools/place-item.ts b/packages/mcp/src/tools/place-item.ts index d026f77d14..5f544e1efb 100644 --- a/packages/mcp/src/tools/place-item.ts +++ b/packages/mcp/src/tools/place-item.ts @@ -1,5 +1,10 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import { geometrySurfaceAt, geometryUndersideAt } from '@pascal-app/core' +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' @@ -97,6 +102,7 @@ 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') { @@ -121,22 +127,25 @@ export function registerPlaceItem(server: McpServer, bridge: SceneOperations): v 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 drop = hanging - ? 'recessed' in baseAsset && baseAsset.recessed - ? 0.02 - : (baseAsset.dimensions?.[1] ?? 0) - : 0 + 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, }) diff --git a/packages/nodes/src/item/authored-face-host.ts b/packages/nodes/src/item/authored-face-host.ts index 82a0a08098..f39ccd4c4f 100644 --- a/packages/nodes/src/item/authored-face-host.ts +++ b/packages/nodes/src/item/authored-face-host.ts @@ -1,7 +1,9 @@ import { type FaceHostCapability, type FaceHostPlacementArgs, + flushMountRotation, type ItemNode, + mountsFlush, sceneRegistry, } from '@pascal-app/core' import { type BufferGeometry, type Mesh, Quaternion, Triangle, Vector3 } from 'three' @@ -44,14 +46,15 @@ function resolveUnderside(args: FaceHostPlacementArgs) { .applyQuaternion(toHost) .normalize() if (normal.y > UNDERSIDE_MAX_NORMAL_Y) return null - return { world, point } + 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. The - * item hangs upright from the point and becomes the object's child. + * 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), @@ -59,12 +62,19 @@ export const authoredItemFaceHost: FaceHostCapability = { resolvePlacement: (args) => { const hit = resolveUnderside(args) if (!hit) return null - const drop = args.asset.recessed ? 0.02 : args.rawDimensions[1] - const position: [number, number, number] = [hit.point.x, hit.point.y - drop, hit.point.z] const yaw = args.draftItem?.rotation[1] ?? 0 - const rotation: [number, number, number] = [0, yaw, 0] - const cursor = hit.world.clone() - cursor.y -= drop + // 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: { diff --git a/packages/nodes/src/item/panel.tsx b/packages/nodes/src/item/panel.tsx index 6e0f2ad670..ff54cf0483 100644 --- a/packages/nodes/src/item/panel.tsx +++ b/packages/nodes/src/item/panel.tsx @@ -162,6 +162,7 @@ export default function ItemPanel() { + @@ -179,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)} + /> + ) +} From 5b16a2530dbf8b18ac7e5a85146dd2edaa5d0cc2 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 2 Oct 2026 11:46:54 -0400 Subject: [PATCH 13/28] =?UTF-8?q?feat(agent-tools):=20find=5Fby=5Ftype=20?= =?UTF-8?q?=E2=80=94=20nodes=20and=20authored=20parts=20of=20one=20type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One shared read-only tool for MCP and chat: nodes of a kind, items of a category, light emitters, and the typed parts recorded in authored objects (a porch's columns, a vault's cans) with level-space bounds. An authored object whose typed parts are all of the type counts as one (a lantern); a mixed one answers with its parts. Inventory: 67 tools. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QUGvAXaawxPa5rnLSE53iZ --- .../core/src/agent-operations/find-by-type.ts | 102 ++++++++++++++++++ packages/core/src/agent-operations/index.ts | 3 + packages/core/src/agent-tools/find-by-type.ts | 16 +++ packages/core/src/agent-tools/index.ts | 3 + .../src/tools/read-tool-annotations.test.ts | 3 +- packages/mcp/src/tools/shared-tools.ts | 6 ++ .../tool-annotation-justifications.json | 13 +++ scripts/openai-tool-annotation-policy.test.ts | 2 +- scripts/openai-tool-annotation-policy.ts | 1 + 9 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/agent-operations/find-by-type.ts create mode 100644 packages/core/src/agent-tools/find-by-type.ts 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 1f00b72d8f..a415374812 100644 --- a/packages/core/src/agent-operations/index.ts +++ b/packages/core/src/agent-operations/index.ts @@ -1,5 +1,6 @@ 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' @@ -10,6 +11,7 @@ export * from './author-object' 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' @@ -30,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/find-by-type.ts b/packages/core/src/agent-tools/find-by-type.ts new file mode 100644 index 0000000000..674e5f118f --- /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 author_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 dea6ab2b4d..9f114ee1c2 100644 --- a/packages/core/src/agent-tools/index.ts +++ b/packages/core/src/agent-tools/index.ts @@ -1,4 +1,5 @@ import { authorObjectTool } from './author-object' +import { findByTypeTool } from './find-by-type' import { duplicateLevelTool, getLevelSummaryTool, @@ -11,6 +12,7 @@ import { deleteNodeTool, getNodeTool } from './nodes' import { addDoorTool, addWindowTool } from './wall-openings' export * from './author-object' +export * from './find-by-type' export * from './levels' export * from './measurement' export { NodeId } from './node-id' @@ -35,4 +37,5 @@ export const AGENT_TOOL_CONTRACTS = [ verifySceneTool, deleteNodeTool, authorObjectTool, + findByTypeTool, ] as const diff --git a/packages/mcp/src/tools/read-tool-annotations.test.ts b/packages/mcp/src/tools/read-tool-annotations.test.ts index a2c3fbae8f..422eed89a0 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', @@ -157,7 +158,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(66) + expect(byName.size).toBe(67) expect([...byName.keys()].toSorted()).toEqual(EXPECTED_TOOL_NAMES) for (const policy of TOOL_POLICIES) { diff --git a/packages/mcp/src/tools/shared-tools.ts b/packages/mcp/src/tools/shared-tools.ts index 0f2b072f15..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, diff --git a/plugin-evals/tool-annotation-justifications.json b/plugin-evals/tool-annotation-justifications.json index 03a3033c00..0cadba3473 100644 --- a/plugin-evals/tool-annotation-justifications.json +++ b/plugin-evals/tool-annotation-justifications.json @@ -383,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": { diff --git a/scripts/openai-tool-annotation-policy.test.ts b/scripts/openai-tool-annotation-policy.test.ts index 31da2de823..f002f5a40f 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 66-tool inventory', + 'OpenAI tool annotation packet must contain the exact 67-tool inventory', ) }) diff --git a/scripts/openai-tool-annotation-policy.ts b/scripts/openai-tool-annotation-policy.ts index 6aae67e5f6..9eb49726e2 100644 --- a/scripts/openai-tool-annotation-policy.ts +++ b/scripts/openai-tool-annotation-policy.ts @@ -19,6 +19,7 @@ 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), author_object: policy(false, true, false), check_collisions: policy(true, false, false), create_from_template: policy(false, true, false), From 9348a994ffa885b4303963f6810a8b544479583d Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 2 Oct 2026 11:57:08 -0400 Subject: [PATCH 14/28] feat(wall): an authored object's cutout cuts its real shape A catalog item's cutout still cuts its bounding box; an authored object's cutout mesh (an arched window, a round porthole) becomes the CSG brush itself, in wall space, its depth stretched to twice the wall thickness so it clears both faces. A flat cutter falls back to the box. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QUGvAXaawxPa5rnLSE53iZ --- .../viewer/src/systems/wall/wall-system.tsx | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index 096873be71..8ce5747e6f 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[], @@ -2034,6 +2074,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.type === 'item' && 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 From ac7b8e853ba0d71c79d17d858ae911bbe0e8f558 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 2 Oct 2026 11:58:08 -0400 Subject: [PATCH 15/28] feat(authored objects): children re-settle when the object is rebuilt A rebuild (param change, code edit; panel, chat or MCP) re-seats the object's children in the same undo step: a resting item back on the surface under it, moved onto the main surface's nearest point when that shrank away (a box on a narrowed porch), a hanging one from the underside above it. The result lists what moved. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QUGvAXaawxPa5rnLSE53iZ --- .../src/agent-operations/author-object.ts | 23 +++++++- packages/core/src/index.ts | 2 + packages/core/src/lib/geometry-surfaces.ts | 59 +++++++++++++++++++ 3 files changed, 81 insertions(+), 3 deletions(-) diff --git a/packages/core/src/agent-operations/author-object.ts b/packages/core/src/agent-operations/author-object.ts index 35cc88922d..3b2519efbd 100644 --- a/packages/core/src/agent-operations/author-object.ts +++ b/packages/core/src/agent-operations/author-object.ts @@ -1,6 +1,6 @@ import { refuse } from '../agent-tools/refusal' import { artifactUrl } from '../lib/artifact-store' -import { geometryRestingHeight } from '../lib/geometry-surfaces' +import { geometryRestingHeight, resettledPosition } from '../lib/geometry-surfaces' import { type AnyNode, type CompiledGeometryScript, @@ -182,9 +182,26 @@ export const authorObject: AgentOperation = (nodes, input, co source: scriptSource(compiled, input.code), 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), - changes: { update: [{ id: next.id, data: next }] }, + 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 } })), + ], + }, } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 672d356bc3..f5184aa15f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -176,6 +176,8 @@ export { geometrySurfaceAt, geometryUndersideAt, mountsFlush, + nearestPointIn, + resettledPosition, } from './lib/geometry-surfaces' export { type ExposedInterval, diff --git a/packages/core/src/lib/geometry-surfaces.ts b/packages/core/src/lib/geometry-surfaces.ts index c99726e4c9..71eea829b8 100644 --- a/packages/core/src/lib/geometry-surfaces.ts +++ b/packages/core/src/lib/geometry-surfaces.ts @@ -137,3 +137,62 @@ export function mountsFlush(asset: { }): 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] +} From 41fcfb2d96112844e92735b56c8572126b23664b Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 2 Oct 2026 11:59:58 -0400 Subject: [PATCH 16/28] chore(geometry-script): release, lockfile, skill and wiki for authored objects - release.yml publishes @pascal-app/geometry-script before the editor that depends on it, in the same version bump. - bun.lock lists the new workspace package. - pascal-3d skill: when to use author_object and find_by_type. - wiki/architecture/authored-objects.md: the model, the module, the naming conventions, compile hosts, agent tools. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QUGvAXaawxPa5rnLSE53iZ --- .github/workflows/release.yml | 21 +++++++++++-- bun.lock | 22 +++++++++++++- packages/geometry-script/LICENSE | 21 +++++++++++++ packages/geometry-script/README.md | 5 +++ skills/pascal-3d/SKILL.md | 2 ++ wiki/architecture/README.md | 1 + wiki/architecture/authored-objects.md | 44 +++++++++++++++++++++++++++ 7 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 packages/geometry-script/LICENSE create mode 100644 packages/geometry-script/README.md create mode 100644 wiki/architecture/authored-objects.md 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/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/skills/pascal-3d/SKILL.md b/skills/pascal-3d/SKILL.md index 96b0435e38..b23415124e 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 `author_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..c3521aba3a --- /dev/null +++ b/wiki/architecture/authored-objects.md @@ -0,0 +1,44 @@ +# 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 module (`code`), its `params`, the hash of the GLB it compiled to (`artifact`) and the `manifest` read from it. `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`. + +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. 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) or answers `scripts_unavailable`. + +## Agent tools + +`author_object` (create or 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). From 042d2420b2d530e516a7ec2360d66657b7e1ed74 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 2 Oct 2026 12:02:01 -0400 Subject: [PATCH 17/28] fix(core): classify authored-manifest keys; keep a literal three import out of core The reference inventory now lists the manifest and script-param keys an authored item defines. The author_object description describes the three import in words: the architecture scan reads core source text. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QUGvAXaawxPa5rnLSE53iZ --- packages/core/src/agent-tools/author-object.ts | 3 +-- .../core/src/contracts/reference-inventory.ts | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/core/src/agent-tools/author-object.ts b/packages/core/src/agent-tools/author-object.ts index 570cd0fc25..3a9e03edb4 100644 --- a/packages/core/src/agent-tools/author-object.ts +++ b/packages/core/src/agent-tools/author-object.ts @@ -4,8 +4,7 @@ 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 * as THREE from 'three' +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' diff --git a/packages/core/src/contracts/reference-inventory.ts b/packages/core/src/contracts/reference-inventory.ts index 8defe4f872..8204479257 100644 --- a/packages/core/src/contracts/reference-inventory.ts +++ b/packages/core/src/contracts/reference-inventory.ts @@ -721,6 +721,21 @@ 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.' }, + ...( + [ + '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: 'item', + path, + reason: "Defines a key in an authored object's compiled manifest, read from its script.", + })), + { kind: 'item', path: 'source.params.@key', reason: 'Authored script parameter name.' }, + { kind: 'item', path: 'source.params.*', reason: 'Authored 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." }, ...[ From e30f17f8349b96b027448e402cc84474cf20e5ce Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 2 Oct 2026 13:00:02 -0400 Subject: [PATCH 18/28] fix(agent-tools): shaped doors and windows go through author_object author_object told models never to build doors or windows, so asked for an arched window with a fan grille the chat offered a rectangular substitute. add_door/add_window make rectangular openings; author_object builds every other shape with a cutout shaped like the opening, and the opening tools say so. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QUGvAXaawxPa5rnLSE53iZ --- packages/core/src/agent-tools/author-object.ts | 4 ++-- packages/core/src/agent-tools/wall-openings.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/src/agent-tools/author-object.ts b/packages/core/src/agent-tools/author-object.ts index 3a9e03edb4..3538d05b47 100644 --- a/packages/core/src/agent-tools/author-object.ts +++ b/packages/core/src/agent-tools/author-object.ts @@ -10,7 +10,7 @@ Module shape (import THREE from the three package as usual; the addons below too 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. +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 or stairs: those have their own tools. Doors and windows: add_door and add_window make rectangular ones; build any other shape here (an arched or round window, a fanlight, grilles, a carved or arched door) with mount 'wall' and a cutout shaped like the opening. 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. @@ -18,7 +18,7 @@ Conventions (they make the object work in Pascal; follow them): - 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 box mesh named cutout (wall mount) is cut out of the host wall and never renders. +- Wall opening: a mesh named cutout (wall mount), shaped like the opening (a box, an arch, a circle) and as deep as the wall or deeper, is cut out of the host wall in that shape and never renders. - 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. diff --git a/packages/core/src/agent-tools/wall-openings.ts b/packages/core/src/agent-tools/wall-openings.ts index c4a50ca2a6..fdb06ea7ca 100644 --- a/packages/core/src/agent-tools/wall-openings.ts +++ b/packages/core/src/agent-tools/wall-openings.ts @@ -23,7 +23,7 @@ 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. Rectangular doors only: build an arched or otherwise shaped door with author_object.', input: { wallId: NodeId.describe('The wall to add the door to.'), ...placement, @@ -53,7 +53,7 @@ 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. Rectangular windows only: build an arched, round or otherwise shaped window (fanlight, grilles) with author_object.", input: { wallId: NodeId.describe('The wall to add the window to.'), ...placement, From 15f11ec417315db8a99e35f76e747da863e917d6 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 2 Oct 2026 13:35:43 -0400 Subject: [PATCH 19/28] feat(agent-tools): add_window/add_door take the outline, type and panes; openings never become authored objects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The opening tools hid what the window and door already do (arched and rounded outlines, opening types, pane grids), so models called arches impossible or built them as items without a mark, schedule row, plan symbol or IfcWindow. add_window/add_door now take openingShape, archHeight, cornerRadius, windowType/doorType and columns × rows; author_object refuses doors and windows. Reverts the 'rectangular only' wording. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QUGvAXaawxPa5rnLSE53iZ --- .../src/agent-operations/author-object.ts | 6 +++ .../core/src/agent-tools/author-object.ts | 4 +- .../core/src/agent-tools/wall-openings.ts | 42 ++++++++++++++++++- packages/core/src/building/wall-openings.ts | 17 ++++++++ 4 files changed, 65 insertions(+), 4 deletions(-) diff --git a/packages/core/src/agent-operations/author-object.ts b/packages/core/src/agent-operations/author-object.ts index 3b2519efbd..7ab30d1d1d 100644 --- a/packages/core/src/agent-operations/author-object.ts +++ b/packages/core/src/agent-operations/author-object.ts @@ -157,6 +157,12 @@ function summary(node: ItemNode, compiled: CompiledGeometryScript, orphanedSlots */ export const authorObject: AgentOperation = (nodes, input, context) => { const { compiled } = input + if (/^(door|window)s?$/i.test(input.category?.trim() ?? '')) + refuse( + 'use_opening_tool', + 'Doors and windows are made with add_door and add_window (rectangle, rounded or arched outlines, panes, types), so they keep their marks, schedules and plan symbols.', + { category: input.category }, + ) const rotation: Vec3 | undefined = input.rotation === undefined ? undefined : [0, (input.rotation * Math.PI) / 180, 0] diff --git a/packages/core/src/agent-tools/author-object.ts b/packages/core/src/agent-tools/author-object.ts index 3538d05b47..d4f94360ee 100644 --- a/packages/core/src/agent-tools/author-object.ts +++ b/packages/core/src/agent-tools/author-object.ts @@ -10,7 +10,7 @@ Module shape (import THREE from the three package as usual; the addons below too 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 or stairs: those have their own tools. Doors and windows: add_door and add_window make rectangular ones; build any other shape here (an arched or round window, a fanlight, grilles, a carved or arched door) with mount 'wall' and a cutout shaped like the opening. +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 (add_window and add_door also make arched and rounded openings). 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. @@ -18,7 +18,7 @@ Conventions (they make the object work in Pascal; follow them): - 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 opening (a box, an arch, a circle) and as deep as the wall or deeper, is cut out of the host wall in that shape and never renders. +- 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. diff --git a/packages/core/src/agent-tools/wall-openings.ts b/packages/core/src/agent-tools/wall-openings.ts index fdb06ea7ca..2c4e38fbf5 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,11 +20,28 @@ const placement = { ), } +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. Rectangular doors only: build an arched or otherwise shaped door with author_object.', + '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.'), ...placement, @@ -40,6 +58,10 @@ export const addDoorTool = { .enum(['inward', 'outward']) .optional() .describe('Which way the door opens (default inward).'), + ...outline('0.45 m'), + doorType: DoorType.optional().describe( + 'How it opens (default hinged); garage types for garage doors.', + ), style: z .enum(DOOR_STYLES) .optional() @@ -53,7 +75,7 @@ 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. Rectangular windows only: build an arched, round or otherwise shaped window (fanlight, grilles) with author_object.", + "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.'), ...placement, @@ -69,6 +91,22 @@ 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'), + 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..9e4ffbed29 100644 --- a/packages/core/src/building/wall-openings.ts +++ b/packages/core/src/building/wall-openings.ts @@ -10,6 +10,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' @@ -171,8 +172,17 @@ 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 } +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 }, @@ -264,6 +274,9 @@ 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 }), } const node = kind === 'door' @@ -272,10 +285,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 { From 56a1bd8199a55a2a39c1e9a71970684250889b6e Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 2 Oct 2026 13:44:58 -0400 Subject: [PATCH 20/28] refactor(author_object): no refusal for doors and windows; the description scopes it Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QUGvAXaawxPa5rnLSE53iZ --- packages/core/src/agent-operations/author-object.ts | 6 ------ packages/core/src/agent-tools/author-object.ts | 2 +- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/core/src/agent-operations/author-object.ts b/packages/core/src/agent-operations/author-object.ts index 7ab30d1d1d..3b2519efbd 100644 --- a/packages/core/src/agent-operations/author-object.ts +++ b/packages/core/src/agent-operations/author-object.ts @@ -157,12 +157,6 @@ function summary(node: ItemNode, compiled: CompiledGeometryScript, orphanedSlots */ export const authorObject: AgentOperation = (nodes, input, context) => { const { compiled } = input - if (/^(door|window)s?$/i.test(input.category?.trim() ?? '')) - refuse( - 'use_opening_tool', - 'Doors and windows are made with add_door and add_window (rectangle, rounded or arched outlines, panes, types), so they keep their marks, schedules and plan symbols.', - { category: input.category }, - ) const rotation: Vec3 | undefined = input.rotation === undefined ? undefined : [0, (input.rotation * Math.PI) / 180, 0] diff --git a/packages/core/src/agent-tools/author-object.ts b/packages/core/src/agent-tools/author-object.ts index d4f94360ee..3ce73fb8ca 100644 --- a/packages/core/src/agent-tools/author-object.ts +++ b/packages/core/src/agent-tools/author-object.ts @@ -10,7 +10,7 @@ Module shape (import THREE from the three package as usual; the addons below too 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 (add_window and add_door also make arched and rounded openings). +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. From 588542b491a20319c0258c5c365816e62621ee54 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 2 Oct 2026 13:53:12 -0400 Subject: [PATCH 21/28] feat(authored objects): the script is stored by hash, never in the scene; read_source source.code becomes source.script, the sha256 of the module stored as a text/javascript artifact, so publishing a scene no longer publishes the code and an edit no longer carries it twice against the operation cap. The compiler returns both hashes and keeps the inline manifest under 24 KiB (outlines thinned to 16 corners, then the smallest surfaces dropped). The artifact store reads text back; author_object rebuilds from params alone; read_source (shared contract, read-only) returns the module and its params. MCP hosts gain readArtifact. Inventory 68. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QUGvAXaawxPa5rnLSE53iZ --- .../src/agent-operations/author-object.ts | 56 ++++++--- .../core/src/agent-tools/author-object.ts | 21 +++- packages/core/src/agent-tools/index.ts | 3 +- packages/core/src/lib/artifact-store.ts | 17 ++- packages/core/src/schema/geometry-source.ts | 15 +-- packages/core/src/schema/index.ts | 2 + packages/editor/src/index.tsx | 1 + .../editor/src/lib/geometry-script/author.ts | 36 ++++-- packages/geometry-script/src/compile.ts | 62 +++++++++- packages/mcp/src/tools/author-object.ts | 113 +++++++++++++++--- packages/mcp/src/tools/index.ts | 3 +- .../src/tools/read-tool-annotations.test.ts | 3 +- .../tool-annotation-justifications.json | 13 ++ scripts/openai-tool-annotation-policy.test.ts | 2 +- scripts/openai-tool-annotation-policy.ts | 1 + wiki/architecture/authored-objects.md | 6 +- 16 files changed, 291 insertions(+), 63 deletions(-) diff --git a/packages/core/src/agent-operations/author-object.ts b/packages/core/src/agent-operations/author-object.ts index 3b2519efbd..f4be9f7b55 100644 --- a/packages/core/src/agent-operations/author-object.ts +++ b/packages/core/src/agent-operations/author-object.ts @@ -15,7 +15,8 @@ import type { AgentOperation } from './types' type Vec3 = [number, number, number] export type AuthorObjectInput = { - code: string + /** Absent for a params-only edit: the host compiled the object's stored script. */ + code?: string params?: Record nodeId?: string parentId?: string @@ -115,10 +116,10 @@ function scriptAsset( } } -const scriptSource = (compiled: CompiledGeometryScript, code: string) => ({ +const scriptSource = (compiled: CompiledGeometryScript) => ({ kind: 'script' as const, language: 'three' as const, - code, + script: compiled.script, params: compiled.params, artifact: compiled.sha256, manifest: compiled.manifest, @@ -161,16 +162,7 @@ export const authorObject: AgentOperation = (nodes, input, co input.rotation === undefined ? undefined : [0, (input.rotation * Math.PI) / 180, 0] if (input.nodeId) { - const previous = nodes[input.nodeId] - if (!previous) - refuse('node_not_found', `Node not found: ${input.nodeId}.`, { id: input.nodeId }) - if (previous.type !== 'item' || !previous.source) { - refuse( - 'not_authored', - `${input.nodeId} is a ${previous.type} without a script; only objects built with author_object can be edited this way.`, - { id: input.nodeId, type: previous.type }, - ) - } + const previous = authoredObject(nodes, input.nodeId) 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({ @@ -179,7 +171,7 @@ export const authorObject: AgentOperation = (nodes, input, co position: (input.position as Vec3 | undefined) ?? previous.position, rotation: rotation ?? previous.rotation, side: input.side ?? previous.side, - source: scriptSource(compiled, input.code), + source: scriptSource(compiled), asset: scriptAsset(compiled, input, previous.asset), }) // Children resting on or hanging from the object follow its new geometry. @@ -226,7 +218,7 @@ export const authorObject: AgentOperation = (nodes, input, co ...(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, input.code), + source: scriptSource(compiled), asset, }) return { @@ -234,3 +226,37 @@ export const authorObject: AgentOperation = (nodes, input, co changes: { create: [{ node, parentId: parent.id }] }, } } + +/** The authored object `read_source` and a params-only rebuild act on, or a refusal. */ +export function authoredObject( + nodes: Record, + nodeId: string, +): ItemNode & { + source: NonNullable +} { + const node = nodes[nodeId] + if (!node) refuse('node_not_found', `Node not found: ${nodeId}.`, { id: nodeId }) + if (node.type !== 'item' || !node.source) + refuse( + 'not_authored', + `${nodeId} is a ${node.type} without a script; only objects built with author_object have one.`, + { id: nodeId, type: node.type }, + ) + return node as ItemNode & { source: NonNullable } +} + +/** What `read_source` answers once the host has the module's text. */ +export function readSourceResult( + node: ItemNode & { source: NonNullable }, + code: string, +) { + return { + nodeId: node.id, + 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-tools/author-object.ts b/packages/core/src/agent-tools/author-object.ts index 3ce73fb8ca..535a73f987 100644 --- a/packages/core/src/agent-tools/author-object.ts +++ b/packages/core/src/agent-tools/author-object.ts @@ -22,14 +22,21 @@ Conventions (they make the object work in Pascal; follow them): - 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; identity, placement and paint are kept. The result lists the size, parts, slots, lights, animations and params.` +Edit: pass nodeId with new code and/or params (params alone rebuild the stored script; read it first with read_source to change the code); identity, placement and paint are kept. The result lists the size, parts, slots, lights, animations and params.` export const authorObjectTool = { name: 'author_object', title: 'Build object', description: DESCRIPTION, input: { - code: z.string().min(1).max(48_000).describe('The three.js module (see the tool description).'), + 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() @@ -62,3 +69,13 @@ export const authorObjectTool = { .describe('What it is, one word or two ("porch", "lantern", "ceiling", "trim").'), }, } + +export const readSourceTool = { + name: 'read_source', + title: 'Read object script', + description: + "The three.js module an object built with author_object runs, with its params and their current values. Read it before changing an object's code, then pass the edited module to author_object with the same nodeId.", + input: { + nodeId: NodeId.describe('An object built with author_object.'), + }, +} diff --git a/packages/core/src/agent-tools/index.ts b/packages/core/src/agent-tools/index.ts index 9f114ee1c2..94e2c870fc 100644 --- a/packages/core/src/agent-tools/index.ts +++ b/packages/core/src/agent-tools/index.ts @@ -1,4 +1,4 @@ -import { authorObjectTool } from './author-object' +import { authorObjectTool, readSourceTool } from './author-object' import { findByTypeTool } from './find-by-type' import { duplicateLevelTool, @@ -37,5 +37,6 @@ export const AGENT_TOOL_CONTRACTS = [ verifySceneTool, deleteNodeTool, authorObjectTool, + readSourceTool, findByTypeTool, ] as const diff --git a/packages/core/src/lib/artifact-store.ts b/packages/core/src/lib/artifact-store.ts index 07e954c1fc..b6d84be72f 100644 --- a/packages/core/src/lib/artifact-store.ts +++ b/packages/core/src/lib/artifact-store.ts @@ -11,16 +11,25 @@ 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, mimeType: string) => Promise + 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 memory = new Map() const memoryStore: ArtifactStore = { - url: (sha256) => memory.get(sha256) ?? null, + url: (sha256) => memory.get(sha256)?.url ?? null, put: async (sha256, bytes, mimeType) => { if (memory.has(sha256)) return - memory.set(sha256, URL.createObjectURL(new Blob([bytes], { type: mimeType }))) + 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 }, } diff --git a/packages/core/src/schema/geometry-source.ts b/packages/core/src/schema/geometry-source.ts index f0020f864b..dc93600622 100644 --- a/packages/core/src/schema/geometry-source.ts +++ b/packages/core/src/schema/geometry-source.ts @@ -2,6 +2,9 @@ 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]) @@ -95,7 +98,10 @@ 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 @@ -109,13 +115,8 @@ export type CompiledGeometryScript = { export const GeometryScriptSource = z.object({ kind: z.literal('script'), language: z.literal('three').default('three'), - code: z - .string() - .min(1) - .refine( - (code) => new TextEncoder().encode(code).byteLength <= GEOMETRY_SCRIPT_MAX_BYTES, - `Script source exceeds ${GEOMETRY_SCRIPT_MAX_BYTES / 1024} KiB`, - ), + /** 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}$/), diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 073ecd84f8..eed1df5a17 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -26,7 +26,9 @@ export { export { CutIntent, CutShape } from './cut' export { type CompiledGeometryScript, + GEOMETRY_MANIFEST_MAX_BYTES, GEOMETRY_SCRIPT_MAX_BYTES, + GEOMETRY_SCRIPT_MIME_TYPE, GeometryArtifactManifest, GeometryScriptMount, GeometryScriptParamSpec, diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index d2b5f8736d..917f5f35cd 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -580,6 +580,7 @@ export { export { compileAndStoreGeometryScript, rebuildAuthoredObject, + storedScript, } from './lib/geometry-script/author' export { compileGeometryScriptInWorker } from './lib/geometry-script/client' export { exportSceneToGlb, type GlbExportOptions } from './lib/glb-export' diff --git a/packages/editor/src/lib/geometry-script/author.ts b/packages/editor/src/lib/geometry-script/author.ts index b22a8a7c89..b2aa2d6353 100644 --- a/packages/editor/src/lib/geometry-script/author.ts +++ b/packages/editor/src/lib/geometry-script/author.ts @@ -1,27 +1,44 @@ import { type AnyNodeId, type CompiledGeometryScript, + GEOMETRY_SCRIPT_MIME_TYPE, type GeometryScriptParamValue, getArtifactStore, useScene, } from '@pascal-app/core' -import { authorObject } from '@pascal-app/core/agent-operations' +import { authoredObject, authorObject } from '@pascal-app/core/agent-operations' import { compileGeometryScriptInWorker } from './client' /** * The editor's compile step for `author_object`: runs the module in the - * worker and stores the artifact, so the core operation can reference it by - * hash. The GLB bytes stay out of the result. + * 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 + code?: string + nodeId?: string params?: Record }): Promise { - const { glb, ...compiled } = await compileGeometryScriptInWorker(input) - await getArtifactStore().put(compiled.sha256, glb, 'model/gltf-binary') + 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() /** @@ -33,16 +50,13 @@ export async function rebuildAuthoredObject( nodeId: string, params: Record, ): Promise { - const node = useScene.getState().nodes[nodeId as AnyNodeId] - if (node?.type !== 'item' || !node.source) throw new Error(`${nodeId} is not an authored object`) const generation = (rebuildGeneration.get(nodeId) ?? 0) + 1 rebuildGeneration.set(nodeId, generation) - const { code } = node.source - const compiled = await compileAndStoreGeometryScript({ code, params }) + const compiled = await compileAndStoreGeometryScript({ nodeId, params }) if (rebuildGeneration.get(nodeId) !== generation) return const { changes } = authorObject( useScene.getState().nodes, - { code, params, nodeId, compiled }, + { params, nodeId, compiled }, { activeLevelId: null }, ) for (const { id, data } of changes?.update ?? []) { diff --git a/packages/geometry-script/src/compile.ts b/packages/geometry-script/src/compile.ts index 6785159661..a355113bb1 100644 --- a/packages/geometry-script/src/compile.ts +++ b/packages/geometry-script/src/compile.ts @@ -5,6 +5,7 @@ import type { 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' @@ -638,7 +639,58 @@ async function exportGlb( return result } -async function digest(bytes: ArrayBuffer): Promise { +/** 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 { const hash = await crypto.subtle.digest('SHA-256', bytes) return Array.from(new Uint8Array(hash), (b) => b.toString(16).padStart(2, '0')).join('') } @@ -650,6 +702,9 @@ const SOURCE_GUARD = 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', @@ -717,9 +772,10 @@ export async function compileGeometryScript( return { glb, sha256: await digest(glb), + script: await digest(new TextEncoder().encode(input.code)), mount, params, - manifest: { + manifest: compactManifest({ bounds: { min: vec(bounds.min), max: vec(bounds.max) }, params: specs, parts: conventions.parts, @@ -735,6 +791,6 @@ export async function compileGeometryScript( cutout: conventions.cutout, collider: conventions.collider, triangles: conventions.triangles, - }, + }), } } diff --git a/packages/mcp/src/tools/author-object.ts b/packages/mcp/src/tools/author-object.ts index 1debdb3225..5a51c6e5df 100644 --- a/packages/mcp/src/tools/author-object.ts +++ b/packages/mcp/src/tools/author-object.ts @@ -1,13 +1,24 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import { type AuthorObjectInput, authorObject } from '@pascal-app/core/agent-operations' -import { authorObjectTool } from '@pascal-app/core/agent-tools' -import type { - AnyNode, - CompiledGeometryScript, - GeometryScriptParamValue, +import { + type AuthorObjectInput, + authoredObject, + authorObject, + readSourceResult, +} from '@pascal-app/core/agent-operations' +import { + authorObjectTool, + isAgentRefusal, + readSourceTool, + 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 } from './annotations' +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' @@ -28,6 +39,20 @@ export type GeometryScriptHost = { 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 +} + +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) } /** `author_object` on the MCP: the shared contract and operation, with the host's compile in front. */ @@ -59,15 +84,29 @@ export function registerAuthorObject( const args = input as Omit let compiled: CompiledGeometryScript try { - const { glb, ...rest } = await host.compile({ code: args.code, params: args.params }) - await host.storeArtifact({ - sceneId: scene.id, - sha256: rest.sha256, - bytes: glb, - mimeType: 'model/gltf-binary', - }) + const code = + args.code ?? + (args.nodeId + ? await readScript(host, scene.id, bridge, args.nodeId) + : refuseMissingCode()) + const { glb, ...rest } = await host.compile({ code, params: args.params }) + await Promise.all([ + host.storeArtifact({ + sceneId: scene.id, + sha256: rest.sha256, + bytes: glb, + mimeType: 'model/gltf-binary', + }), + host.storeArtifact({ + sceneId: scene.id, + sha256: rest.script, + bytes: new TextEncoder().encode(code), + mimeType: GEOMETRY_SCRIPT_MIME_TYPE, + }), + ]) compiled = rest } catch (error) { + if (isAgentRefusal(error)) return refusalResult(error) return toolError(error instanceof Error ? error.message : String(error), { code: 'script_failed', }) @@ -95,3 +134,49 @@ export function registerAuthorObject( }, ) } + +function refuseMissingCode(): never { + refuse( + 'code_required', + 'Pass code to build a new object; params alone rebuild an existing one (nodeId).', + ) +} + +/** `read_source` on the MCP: the object's module text, read back through the host's store. */ +export function registerReadSource( + server: McpServer, + bridge: SceneOperations, + host: GeometryScriptHost | undefined, +): void { + server.registerTool( + readSourceTool.name, + { + title: readSourceTool.title, + description: readSourceTool.description, + inputSchema: readSourceTool.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 1c8304cb76..517cefeeaf 100644 --- a/packages/mcp/src/tools/index.ts +++ b/packages/mcp/src/tools/index.ts @@ -1,7 +1,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { SceneOperations } from '../operations' import { registerApplyPatch } from './apply-patch' -import { type GeometryScriptHost, registerAuthorObject } from './author-object' +import { type GeometryScriptHost, registerAuthorObject, registerReadSource } from './author-object' import { registerCheckCollisions } from './check-collisions' import { registerConstructionTools } from './construction-tools' import { registerCreateLevel } from './create-level' @@ -49,6 +49,7 @@ export function registerTools( registerFindNodes(server, operations) registerSharedTools(server, operations) registerAuthorObject(server, operations, geometryScripts) + registerReadSource(server, operations, geometryScripts) registerMeasure(server, operations) registerConstructionTools(server, operations) registerRoomTools(server, operations) diff --git a/packages/mcp/src/tools/read-tool-annotations.test.ts b/packages/mcp/src/tools/read-tool-annotations.test.ts index 422eed89a0..17436dd4a5 100644 --- a/packages/mcp/src/tools/read-tool-annotations.test.ts +++ b/packages/mcp/src/tools/read-tool-annotations.test.ts @@ -33,6 +33,7 @@ const TOOL_POLICIES = [ 'list_templates', 'list_units', 'measure', + 'read_source', 'search_assets', 'validate_design', 'validate_scene', @@ -158,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(67) + expect(byName.size).toBe(68) expect([...byName.keys()].toSorted()).toEqual(EXPECTED_TOOL_NAMES) for (const policy of TOOL_POLICIES) { diff --git a/plugin-evals/tool-annotation-justifications.json b/plugin-evals/tool-annotation-justifications.json index 0cadba3473..39e567aae8 100644 --- a/plugin-evals/tool-annotation-justifications.json +++ b/plugin-evals/tool-annotation-justifications.json @@ -669,6 +669,19 @@ "openWorldHint": "It operates on the connected Pascal scene, project, and configured catalog data without accessing the public internet." } }, + { + "name": "read_source", + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "openWorldHint": false + }, + "justifications": { + "readOnlyHint": "It only reads the script stored for an object built with author_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": "rebase_floor_reference", "annotations": { diff --git a/scripts/openai-tool-annotation-policy.test.ts b/scripts/openai-tool-annotation-policy.test.ts index f002f5a40f..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 67-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 9eb49726e2..3c2adf77f3 100644 --- a/scripts/openai-tool-annotation-policy.ts +++ b/scripts/openai-tool-annotation-policy.ts @@ -20,6 +20,7 @@ export const EXPECTED_OPENAI_TOOL_ANNOTATIONS = { analyze_room_photo: policy(true, false, true), apply_patch: policy(false, true, false), find_by_type: policy(true, false, false), + read_source: policy(true, false, false), author_object: policy(false, true, false), check_collisions: policy(true, false, false), create_from_template: policy(false, true, false), diff --git a/wiki/architecture/authored-objects.md b/wiki/architecture/authored-objects.md index c3521aba3a..1fd85d068b 100644 --- a/wiki/architecture/authored-objects.md +++ b/wiki/architecture/authored-objects.md @@ -6,7 +6,7 @@ Applies to: `packages/geometry-script/**`, `item.source` in `packages/core/src/s ## The model -An authored object is an `item` whose `source` holds the module (`code`), its `params`, the hash of the GLB it compiled to (`artifact`) and the `manifest` read from it. `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`. +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`. 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). @@ -37,8 +37,8 @@ The compiler also derives upward surfaces (where things rest) and undersides (wh ## Compiling -`@pascal-app/geometry-script` compiles a module to a GLB and manifest in a browser worker, Bun or Node. 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) or answers `scripts_unavailable`. +`@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 -`author_object` (create or 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). +`author_object` (create, or edit by `nodeId`: new code, or params alone to rebuild the stored script), `read_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). From 3fd261441ffbf05e8b3df17d38292456c604a3c6 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 2 Oct 2026 14:21:00 -0400 Subject: [PATCH 22/28] feat(openings): windows and doors built from a script, through the item's pipeline A window or door takes the same source as an authored item when its fields cannot express the design. add_window/add_door accept code and params (compiled and stored as author_object does, on MCP and in the chat); the size is the compiled bounds. The window and door systems keep the hitbox and wall placement but leave the frame to the renderer, which shows the artifact through the item's model path (paint slots, clips, lights, settling); the wall cuts the script's cutout mesh, or the outline until it loads. The Parameters panel replaces the parametric frame's fields, the open clip is the Open control, author_object edits and read_source reads any scripted node. Script helpers (scriptSource, scriptInteractive, scriptedSize) move to core/lib/geometry-script-node. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QUGvAXaawxPa5rnLSE53iZ --- .../src/agent-operations/author-object.ts | 114 ++++----- .../core/src/agent-tools/author-object.ts | 6 +- .../core/src/agent-tools/wall-openings.ts | 17 ++ packages/core/src/building/wall-openings.ts | 13 +- .../core/src/contracts/reference-inventory.ts | 33 +-- packages/core/src/index.ts | 7 + packages/core/src/lib/geometry-script-node.ts | 81 +++++++ packages/core/src/schema/nodes/door.ts | 6 + packages/core/src/schema/nodes/item.ts | 2 +- packages/core/src/schema/nodes/window.ts | 6 + packages/mcp/src/tools/author-object.ts | 42 ++-- packages/mcp/src/tools/index.ts | 2 +- packages/mcp/src/tools/room-tools.ts | 60 ++++- packages/nodes/src/door/mechanism.ts | 11 +- packages/nodes/src/door/panel.tsx | 223 +++++++++--------- packages/nodes/src/door/renderer.tsx | 2 + packages/nodes/src/item/authored-params.tsx | 12 +- packages/nodes/src/item/renderer.tsx | 57 ++++- .../nodes/src/shared/scripted-opening.tsx | 85 +++++++ packages/nodes/src/window/mechanism.ts | 16 +- packages/nodes/src/window/panel.tsx | 147 ++++++------ packages/nodes/src/window/renderer.tsx | 2 + packages/viewer/src/index.ts | 1 + packages/viewer/src/lib/scripted-opening.ts | 23 ++ .../viewer/src/systems/door/door-system.tsx | 13 +- .../viewer/src/systems/wall/wall-system.tsx | 10 +- .../src/systems/window/window-system.tsx | 35 +-- wiki/architecture/authored-objects.md | 2 + 28 files changed, 700 insertions(+), 328 deletions(-) create mode 100644 packages/core/src/lib/geometry-script-node.ts create mode 100644 packages/nodes/src/shared/scripted-opening.tsx create mode 100644 packages/viewer/src/lib/scripted-opening.ts diff --git a/packages/core/src/agent-operations/author-object.ts b/packages/core/src/agent-operations/author-object.ts index f4be9f7b55..94445d3c7b 100644 --- a/packages/core/src/agent-operations/author-object.ts +++ b/packages/core/src/agent-operations/author-object.ts @@ -1,5 +1,12 @@ 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, @@ -44,54 +51,6 @@ const HOSTS: Record = { ceiling: ['ceiling'], } -/** - * 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. - */ -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 -} - function scriptAsset( compiled: CompiledGeometryScript, input: AuthorObjectInput, @@ -116,18 +75,9 @@ function scriptAsset( } } -const scriptSource = (compiled: CompiledGeometryScript) => ({ - kind: 'script' as const, - language: 'three' as const, - script: compiled.script, - params: compiled.params, - artifact: compiled.sha256, - manifest: compiled.manifest, -}) - const round = (value: number) => Math.round(value * 1000) / 1000 -function summary(node: ItemNode, compiled: CompiledGeometryScript, orphanedSlots: string[]) { +function summary(node: { id: string }, compiled: CompiledGeometryScript, orphanedSlots: string[]) { const { bounds, parts, slots, lights, params, triangles, cutout, animations } = compiled.manifest return { nodeId: node.id, @@ -165,6 +115,33 @@ export const authorObject: AgentOperation = (nodes, input, co const previous = authoredObject(nodes, input.nodeId) const slotIds = new Set(compiled.manifest.slots.map((slot) => slot.id)) const orphanedSlots = Object.keys(previous.slots ?? {}).filter((id) => !slotIds.has(id)) + if (previous.type !== 'item') { + // 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) + const [x, y, z] = (input.position as Vec3 | undefined) ?? previous.position + const bottom = y - previous.height / 2 + return { + result: summary(previous, compiled, orphanedSlots), + changes: { + update: [ + { + id: previous.id, + data: { + name: input.name ?? previous.name, + source: scriptSource(compiled), + width, + height, + position: [x, bottom + height / 2, z], + }, + }, + ], + }, + } + } const next = ItemNode.parse({ ...previous, name: input.name ?? previous.name, @@ -227,31 +204,24 @@ export const authorObject: AgentOperation = (nodes, input, co } } -/** The authored object `read_source` and a params-only rebuild act on, or a refusal. */ -export function authoredObject( - nodes: Record, - nodeId: string, -): ItemNode & { - source: NonNullable -} { +/** The scripted node `read_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 (node.type !== 'item' || !node.source) + if (!isScriptedNode(node)) refuse( 'not_authored', - `${nodeId} is a ${node.type} without a script; only objects built with author_object have one.`, + `${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 as ItemNode & { source: NonNullable } + return node } /** What `read_source` answers once the host has the module's text. */ -export function readSourceResult( - node: ItemNode & { source: NonNullable }, - code: string, -) { +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) => ({ diff --git a/packages/core/src/agent-tools/author-object.ts b/packages/core/src/agent-tools/author-object.ts index 535a73f987..aa7279098e 100644 --- a/packages/core/src/agent-tools/author-object.ts +++ b/packages/core/src/agent-tools/author-object.ts @@ -22,7 +22,7 @@ Conventions (they make the object work in Pascal; follow them): - 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 read_source to change the code); identity, placement and paint are kept. The result lists the size, parts, slots, lights, animations and params.` +Edit: pass nodeId (an object, or a window or door built from code) with new code and/or params (params alone rebuild the stored script; read it first with read_source to change the code); identity, placement and paint are kept. The result lists the size, parts, slots, lights, animations and params.` export const authorObjectTool = { name: 'author_object', @@ -74,8 +74,8 @@ export const readSourceTool = { name: 'read_source', title: 'Read object script', description: - "The three.js module an object built with author_object runs, with its params and their current values. Read it before changing an object's code, then pass the edited module to author_object with the same nodeId.", + "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 an object's code, then pass the edited module to author_object with the same nodeId.", input: { - nodeId: NodeId.describe('An object built with author_object.'), + nodeId: NodeId.describe('An object, window or door built from code.'), }, } diff --git a/packages/core/src/agent-tools/wall-openings.ts b/packages/core/src/agent-tools/wall-openings.ts index 2c4e38fbf5..e52bdc0b1d 100644 --- a/packages/core/src/agent-tools/wall-openings.ts +++ b/packages/core/src/agent-tools/wall-openings.ts @@ -20,6 +20,21 @@ 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 author_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. 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']) @@ -59,6 +74,7 @@ export const addDoorTool = { .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.', ), @@ -92,6 +108,7 @@ export const addWindowTool = { 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() diff --git a/packages/core/src/building/wall-openings.ts b/packages/core/src/building/wall-openings.ts index 9e4ffbed29..a11c22f565 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, @@ -179,6 +181,8 @@ export type WallOpeningInput = { 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) @@ -226,8 +230,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( @@ -277,6 +285,7 @@ export function planWallOpening(nodes: Nodes, input: WallOpeningInput) { ...(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' diff --git a/packages/core/src/contracts/reference-inventory.ts b/packages/core/src/contracts/reference-inventory.ts index 8204479257..a81e2d1463 100644 --- a/packages/core/src/contracts/reference-inventory.ts +++ b/packages/core/src/contracts/reference-inventory.ts @@ -721,21 +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.' }, - ...( - [ - '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: 'item', - path, - reason: "Defines a key in an authored object's compiled manifest, read from its script.", - })), - { kind: 'item', path: 'source.params.@key', reason: 'Authored script parameter name.' }, - { kind: 'item', path: 'source.params.*', reason: 'Authored script parameter value.' }, + // 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/index.ts b/packages/core/src/index.ts index f5184aa15f..8e0affd8bf 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -170,6 +170,13 @@ export { withFloorStepOverride, withoutFloorStepOverrideKeys, } from './lib/floor-step-finish' +export { + isScriptedNode, + type ScriptedNode, + scriptedSize, + scriptInteractive, + scriptSource, +} from './lib/geometry-script-node' export { flushMountRotation, geometryRestingHeight, 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/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 eb3c06904c..aa0dc1dd35 100644 --- a/packages/core/src/schema/nodes/item.ts +++ b/packages/core/src/schema/nodes/item.ts @@ -196,7 +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 (code, params, artifact hash, manifest) + - 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/mcp/src/tools/author-object.ts b/packages/mcp/src/tools/author-object.ts index 5a51c6e5df..f6c88743e2 100644 --- a/packages/mcp/src/tools/author-object.ts +++ b/packages/mcp/src/tools/author-object.ts @@ -43,6 +43,31 @@ export type GeometryScriptHost = { 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 +} + async function readScript( host: GeometryScriptHost, sceneId: string, @@ -89,22 +114,7 @@ export function registerAuthorObject( (args.nodeId ? await readScript(host, scene.id, bridge, args.nodeId) : refuseMissingCode()) - const { glb, ...rest } = await host.compile({ code, params: args.params }) - await Promise.all([ - host.storeArtifact({ - sceneId: scene.id, - sha256: rest.sha256, - bytes: glb, - mimeType: 'model/gltf-binary', - }), - host.storeArtifact({ - sceneId: scene.id, - sha256: rest.script, - bytes: new TextEncoder().encode(code), - mimeType: GEOMETRY_SCRIPT_MIME_TYPE, - }), - ]) - compiled = rest + 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), { diff --git a/packages/mcp/src/tools/index.ts b/packages/mcp/src/tools/index.ts index 517cefeeaf..80c5d5b832 100644 --- a/packages/mcp/src/tools/index.ts +++ b/packages/mcp/src/tools/index.ts @@ -52,7 +52,7 @@ export function registerTools( registerReadSource(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/room-tools.ts b/packages/mcp/src/tools/room-tools.ts index 6bb3025b3d..b18debcc19 100644 --- a/packages/mcp/src/tools/room-tools.ts +++ b/packages/mcp/src/tools/room-tools.ts @@ -18,6 +18,8 @@ import type { AnyNode, AnyNodeId, AssetInput, + CompiledGeometryScript, + GeometryScriptParamValue, WallNode as WallNodeType, } from '@pascal-app/core/schema' import { ItemNode } from '@pascal-app/core/schema' @@ -25,7 +27,8 @@ import { z } from 'zod' import type { SceneOperations } from '../operations' import { ADDITIVE_TOOL_ANNOTATIONS, READ_ONLY_TOOL_ANNOTATIONS } from './annotations' import { findCatalogItem, searchCatalogItems } from './asset-catalog' -import { ErrorCode, refusalResult, throwMcpError } from './errors' +import { compileAndStore, type GeometryScriptHost } from './author-object' +import { ErrorCode, refusalResult, throwMcpError, toolError } from './errors' import { type LiveSyncStatus, liveSyncOutput, @@ -536,7 +539,38 @@ export function registerCreateRoom(server: McpServer, bridge: SceneOperations): ) } -export function registerAddDoor(server: McpServer, bridge: SceneOperations): void { +/** A door or window passed `code`: compiled and stored the way author_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 +581,14 @@ export function registerAddDoor(server: McpServer, bridge: SceneOperations): voi annotations: ADDITIVE_TOOL_ANNOTATIONS, }, async (input) => { + 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 +609,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 +624,14 @@ export function registerAddWindow(server: McpServer, bridge: SceneOperations): v annotations: ADDITIVE_TOOL_ANNOTATIONS, }, async (input) => { + 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 +777,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/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-params.tsx b/packages/nodes/src/item/authored-params.tsx index 306969b97f..ed82aafba6 100644 --- a/packages/nodes/src/item/authored-params.tsx +++ b/packages/nodes/src/item/authored-params.tsx @@ -1,6 +1,10 @@ 'use client' -import type { GeometryScriptParamSpec, GeometryScriptParamValue, ItemNode } from '@pascal-app/core' +import type { + GeometryScriptParamSpec, + GeometryScriptParamValue, + ScriptedNode, +} from '@pascal-app/core' import { PanelSection, rebuildAuthoredObject, @@ -15,7 +19,11 @@ import { useState } from 'react' * 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: ItemNode }) { +export function AuthoredParams({ + node, +}: { + node: { id: string; source?: ScriptedNode['source'] } +}) { const source = node.source const [drafts, setDrafts] = useState>({}) const [busy, setBusy] = useState(false) diff --git a/packages/nodes/src/item/renderer.tsx b/packages/nodes/src/item/renderer.tsx index 1d0e2b6ed1..604217d8a4 100644 --- a/packages/nodes/src/item/renderer.tsx +++ b/packages/nodes/src/item/renderer.tsx @@ -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!) @@ -729,7 +752,7 @@ const LoadedModelRenderer = ({ ref={ref} rotation={node.asset.rotation} scale={node.asset.scale || [1, 1, 1]} - {...handlers} + {...(events ? handlers : {})} /> {animations.length > 0 && scripted && interactive && ( @@ -975,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/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/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/scripted-opening.ts b/packages/viewer/src/lib/scripted-opening.ts new file mode 100644 index 0000000000..28c71da7c9 --- /dev/null +++ b/packages/viewer/src/lib/scripted-opening.ts @@ -0,0 +1,23 @@ +import type * as THREE from 'three' + +/** The group a scripted window or door renders its artifact into; the opening's system leaves it alone. */ +export const SCRIPTED_MODEL_FLAG = 'scriptedModel' + +/** + * A window or door built from a script renders its artifact, not the + * parametric frame: drop what an earlier parametric build left under the + * mesh, and report whether the artifact has loaded (until then the node + * stays dirty, so scene-ready and bakes wait for it, as for items). + */ +export function settleScriptedOpening(mesh: THREE.Object3D): boolean { + let settled = false + for (const child of [...mesh.children]) { + if (child.userData[SCRIPTED_MODEL_FLAG]) { + settled = child.userData.itemModelSettled === true + continue + } + mesh.remove(child) + child.traverse((object) => (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..d4b4f12434 100644 --- a/packages/viewer/src/systems/door/door-system.tsx +++ b/packages/viewer/src/systems/door/door-system.tsx @@ -30,6 +30,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 +179,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 +2304,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 +2322,9 @@ 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. + if (node.source) 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 +2374,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { if (openingKind === 'opening') { syncDoorCutout(node, mesh) - return + return true } const insideWidth = width - 2 * frameThickness @@ -2687,6 +2693,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 8ce5747e6f..4c2f18a634 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -1940,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, @@ -2076,7 +2082,7 @@ function collectCutoutBrushes( // 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.type === 'item' && child.source) { + if (child.source) { const shaped = authoredCutoutBrush(cutoutMesh, wallMatrixInverse, wallThickness, wallNode) if (shaped) { brushes.push(shaped) diff --git a/packages/viewer/src/systems/window/window-system.tsx b/packages/viewer/src/systems/window/window-system.tsx index 610c05a7b4..54973104b2 100644 --- a/packages/viewer/src/systems/window/window-system.tsx +++ b/packages/viewer/src/systems/window/window-system.tsx @@ -26,6 +26,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 +160,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 +3374,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 +3391,9 @@ 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. + if (node.source) 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 +3426,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 +3649,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { } syncWindowCutout(node, mesh) + return true } function syncWindowCutout(node: WindowNode, mesh: THREE.Mesh) { diff --git a/wiki/architecture/authored-objects.md b/wiki/architecture/authored-objects.md index 1fd85d068b..9083002874 100644 --- a/wiki/architecture/authored-objects.md +++ b/wiki/architecture/authored-objects.md @@ -8,6 +8,8 @@ Applies to: `packages/geometry-script/**`, `item.source` in `packages/core/src/s 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, `author_object` edits it by `nodeId` and `read_source` reads it. 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 From 77bc1ff8123b4abfb7f190b076c416c3b85a8eb2 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 2 Oct 2026 14:32:51 -0400 Subject: [PATCH 23/28] fix(geometry-script): clips are stored ending on their last pose; author_object scripts a native opening The compiler re-samples clips through a mixer whose action looped, so the sample at the clip's end wrapped to its first frame: an open clip opened, then snapped shut, and closing (open reversed) ended open. The action now plays once and holds; a regression test reads the stored GLB back. author_object with new code may give an existing window or door its script, keeping its place and mark, instead of the agent deleting and re-adding it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QUGvAXaawxPa5rnLSE53iZ --- .../src/agent-operations/author-object.ts | 17 ++++++++++- .../core/src/agent-tools/author-object.ts | 2 +- packages/geometry-script/bunfig.toml | 4 +++ packages/geometry-script/package.json | 3 +- packages/geometry-script/src/compile.test.ts | 28 +++++++++++++++++++ packages/geometry-script/src/compile.ts | 4 +++ 6 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 packages/geometry-script/bunfig.toml create mode 100644 packages/geometry-script/src/compile.test.ts diff --git a/packages/core/src/agent-operations/author-object.ts b/packages/core/src/agent-operations/author-object.ts index 94445d3c7b..d18d99f10c 100644 --- a/packages/core/src/agent-operations/author-object.ts +++ b/packages/core/src/agent-operations/author-object.ts @@ -11,10 +11,12 @@ import { geometryRestingHeight, resettledPosition } from '../lib/geometry-surfac import { type AnyNode, type CompiledGeometryScript, + type DoorNode, type GeometryScriptMount, type GeometryScriptParamValue, generateId, ItemNode, + type WindowNode, } from '../schema' import { targetLevel } from './level-target' import type { AgentOperation } from './types' @@ -112,7 +114,10 @@ export const authorObject: AgentOperation = (nodes, input, co input.rotation === undefined ? undefined : [0, (input.rotation * Math.PI) / 180, 0] if (input.nodeId) { - const previous = authoredObject(nodes, input.nodeId) + // New code may also give a native window or door its script; params alone need one already. + const previous = input.code + ? scriptTarget(nodes, input.nodeId) + : authoredObject(nodes, input.nodeId) const slotIds = new Set(compiled.manifest.slots.map((slot) => slot.id)) const orphanedSlots = Object.keys(previous.slots ?? {}).filter((id) => !slotIds.has(id)) if (previous.type !== 'item') { @@ -217,6 +222,16 @@ export function authoredObject(nodes: Record, nodeId: string): return node } +/** What `author_object` with new code may edit: a scripted node, or a window or door taking its first script. */ +function scriptTarget( + nodes: Record, + nodeId: string, +): ScriptedNode | WindowNode | DoorNode { + const node = nodes[nodeId] + if (node?.type === 'window' || node?.type === 'door') return node + return authoredObject(nodes, nodeId) +} + /** What `read_source` answers once the host has the module's text. */ export function readSourceResult(node: ScriptedNode, code: string) { return { diff --git a/packages/core/src/agent-tools/author-object.ts b/packages/core/src/agent-tools/author-object.ts index aa7279098e..83f3fab3e3 100644 --- a/packages/core/src/agent-tools/author-object.ts +++ b/packages/core/src/agent-tools/author-object.ts @@ -22,7 +22,7 @@ Conventions (they make the object work in Pascal; follow them): - 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 (an object, or a window or door built from code) with new code and/or params (params alone rebuild the stored script; read it first with read_source to change the code); identity, placement and paint are kept. The result lists the size, parts, slots, lights, animations and params.` +Edit: pass nodeId (an object, or any window or door: code gives it a script, keeping its place and mark) with new code and/or params (params alone rebuild the stored script; read it first with read_source to change the code); identity, placement and paint are kept. The result lists the size, parts, slots, lights, animations and params.` export const authorObjectTool = { name: 'author_object', 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 index 5956f4118e..c776ff3b46 100644 --- a/packages/geometry-script/package.json +++ b/packages/geometry-script/package.json @@ -25,7 +25,8 @@ "build": "tsc --build", "dev": "tsgo --build --watch", "check-types": "tsgo --noEmit", - "prepublishOnly": "npm run build" + "prepublishOnly": "npm run build", + "test": "bun test" }, "dependencies": { "@pascal-app/core": "^1.0.3", 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 index a355113bb1..fcfe53b87d 100644 --- a/packages/geometry-script/src/compile.ts +++ b/packages/geometry-script/src/compile.ts @@ -565,6 +565,10 @@ function sampleTransformClips(root: THREE.Object3D, clips: THREE.AnimationClip[] 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) From ad3ec29130dd109e0fc5fd50d2dc6aeef0db3cc4 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 2 Oct 2026 14:55:36 -0400 Subject: [PATCH 24/28] feat(openings): scripted openings show only the handles their params drive; scripts add an open clip A window or door built from a script kept the parametric arrows and rounding dots, which moved its box without changing the script. Its handles now come from the script: a width param keeps the side arrows, a height param the top arrow, both rebuilding the script on release (the opposite edge kept, in one undo step); without them only the door's move handle shows. The 2D width arrows hide on scripted openings. add_door/add_window ask for an open clip unless the opening is fixed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QUGvAXaawxPa5rnLSE53iZ --- .../core/src/agent-tools/wall-openings.ts | 2 +- .../editor/src/lib/geometry-script/author.ts | 8 ++- packages/nodes/src/door/definition.ts | 6 +- packages/nodes/src/door/floorplan.ts | 39 ++++++------ .../src/shared/opening-radius-handles.test.ts | 8 ++- .../shared/scripted-opening-handles.test.ts | 63 +++++++++++++++++++ .../src/shared/scripted-opening-handles.ts | 51 +++++++++++++++ packages/nodes/src/window/definition.test.ts | 6 +- packages/nodes/src/window/definition.ts | 6 +- packages/nodes/src/window/floorplan.ts | 39 ++++++------ 10 files changed, 182 insertions(+), 46 deletions(-) create mode 100644 packages/nodes/src/shared/scripted-opening-handles.test.ts create mode 100644 packages/nodes/src/shared/scripted-opening-handles.ts diff --git a/packages/core/src/agent-tools/wall-openings.ts b/packages/core/src/agent-tools/wall-openings.ts index e52bdc0b1d..152c85138d 100644 --- a/packages/core/src/agent-tools/wall-openings.ts +++ b/packages/core/src/agent-tools/wall-openings.ts @@ -27,7 +27,7 @@ const script = (kind: string) => ({ .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 author_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. Fields first; code only beyond them.`, + `A three.js module for a ${kind} the fields cannot express (a fan grille, tracery, carved trim): the same module and conventions as author_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()])) diff --git a/packages/editor/src/lib/geometry-script/author.ts b/packages/editor/src/lib/geometry-script/author.ts index b2aa2d6353..abf21d23ab 100644 --- a/packages/editor/src/lib/geometry-script/author.ts +++ b/packages/editor/src/lib/geometry-script/author.ts @@ -42,13 +42,15 @@ export async function storedScript(nodeId: string | undefined): Promise const rebuildGeneration = new Map() /** - * Re-runs an authored object's script with new param values, the inspector's - * path: same code, new artifact, one undo step. A slower earlier rebuild of + * 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) @@ -56,7 +58,7 @@ export async function rebuildAuthoredObject( if (rebuildGeneration.get(nodeId) !== generation) return const { changes } = authorObject( useScene.getState().nodes, - { params, nodeId, compiled }, + { params, nodeId, compiled, position }, { activeLevelId: null }, ) for (const { id, data } of changes?.update ?? []) { 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/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/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 From 63df9f15fe29d57e4d324a9aa34af71661f2056a Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 2 Oct 2026 15:14:35 -0400 Subject: [PATCH 25/28] fix(openings): scripted openings export their clips, place the toolbar on what renders, and open by default - GLB export: a window or door built from a script bakes its own clips (through the item path) instead of the parametric door/window baker, so its open/close reaches the file. - The selection toolbar of a scripted node sits above its visible meshes, not its hidden hit box, cutout and collider; without a height param there is no height arrow to clear, so it uses the default lift. - The hit box of a scripted opening is the script's bounds, never stale width/height fields. - author_object's motion rule asks a window or door for an open clip (the path that scripts an existing door), and a position passed to it is taken as given; only without one does the bottom edge stay put. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QUGvAXaawxPa5rnLSE53iZ --- .../src/agent-operations/author-object.ts | 11 ++++-- .../core/src/agent-tools/author-object.ts | 2 +- .../editor/floating-action-menu.tsx | 37 ++++++++++++++++++- packages/editor/src/lib/glb-export.ts | 18 +++++---- .../viewer/src/systems/door/door-system.tsx | 10 ++++- .../src/systems/window/window-system.tsx | 10 ++++- 6 files changed, 72 insertions(+), 16 deletions(-) diff --git a/packages/core/src/agent-operations/author-object.ts b/packages/core/src/agent-operations/author-object.ts index d18d99f10c..9a40c98bef 100644 --- a/packages/core/src/agent-operations/author-object.ts +++ b/packages/core/src/agent-operations/author-object.ts @@ -127,8 +127,13 @@ export const authorObject: AgentOperation = (nodes, input, co mount: compiled.mount, }) const [width, height] = scriptedSize(compiled.manifest) - const [x, y, z] = (input.position as Vec3 | undefined) ?? previous.position - const bottom = y - previous.height / 2 + // Given a position, that is where it goes; otherwise its bottom edge stays put. + const [x, y, z] = previous.position + const position: Vec3 = (input.position as Vec3 | undefined) ?? [ + x, + y - previous.height / 2 + height / 2, + z, + ] return { result: summary(previous, compiled, orphanedSlots), changes: { @@ -140,7 +145,7 @@ export const authorObject: AgentOperation = (nodes, input, co source: scriptSource(compiled), width, height, - position: [x, bottom + height / 2, z], + position, }, }, ], diff --git a/packages/core/src/agent-tools/author-object.ts b/packages/core/src/agent-tools/author-object.ts index 83f3fab3e3..a8d92d03c0 100644 --- a/packages/core/src/agent-tools/author-object.ts +++ b/packages/core/src/agent-tools/author-object.ts @@ -17,7 +17,7 @@ Conventions (they make the object work in Pascal; follow them): - 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. +- 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. A window or door opens with its open clip (the sash, the leaves): write one unless it is fixed. - 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. 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/lib/glb-export.ts b/packages/editor/src/lib/glb-export.ts index c6bf5224e9..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] diff --git a/packages/viewer/src/systems/door/door-system.tsx b/packages/viewer/src/systems/door/door-system.tsx index d4b4f12434..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, @@ -2322,8 +2323,13 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh): boolean { mesh.position.set(...placement.position) mesh.rotation.set(...placement.rotation) - // Built from a script: the renderer shows its artifact, not the parametric frame. - if (node.source) return settleScriptedOpening(mesh) + // 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]) { diff --git a/packages/viewer/src/systems/window/window-system.tsx b/packages/viewer/src/systems/window/window-system.tsx index 54973104b2..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, @@ -3391,8 +3392,13 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh): boolean { mesh.position.set(...placement.position) mesh.rotation.set(...placement.rotation) - // Built from a script: the renderer shows its artifact, not the parametric frame. - if (node.source) return settleScriptedOpening(mesh) + // 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]) { From ff8b3deb1838adc3308354223a7e37c3031dfa59 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 2 Oct 2026 15:27:19 -0400 Subject: [PATCH 26/28] feat(openings): add_window/add_door rebuild an opening by nodeId; author_object is for objects The door's guidance (an open clip, width/height params) lives on add_door, but every update went through author_object, which never mentions doors: the scripted door came out without its open clip. A window or door is now created and rebuilt through its own tool: add_window/add_door take nodeId (new code, or the stored script with new params; wallId becomes optional) through a rescriptOpening operation shared by the chat, the MCP, the Parameters panel and the resize arrows. author_object refuses an opening's id with where to go, and its description drops the door sentence. A wider rebuild stays on its wall. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QUGvAXaawxPa5rnLSE53iZ --- .../src/agent-operations/author-object.ts | 120 +++++++++++------- .../core/src/agent-tools/author-object.ts | 6 +- .../core/src/agent-tools/wall-openings.ts | 10 +- packages/core/src/building/wall-openings.ts | 7 +- .../editor/src/lib/geometry-script/author.ts | 12 +- packages/mcp/src/tools/author-object.ts | 2 +- packages/mcp/src/tools/room-tools.ts | 67 +++++++++- wiki/architecture/authored-objects.md | 2 +- 8 files changed, 162 insertions(+), 64 deletions(-) diff --git a/packages/core/src/agent-operations/author-object.ts b/packages/core/src/agent-operations/author-object.ts index 9a40c98bef..151248a3e4 100644 --- a/packages/core/src/agent-operations/author-object.ts +++ b/packages/core/src/agent-operations/author-object.ts @@ -11,12 +11,10 @@ import { geometryRestingHeight, resettledPosition } from '../lib/geometry-surfac import { type AnyNode, type CompiledGeometryScript, - type DoorNode, type GeometryScriptMount, type GeometryScriptParamValue, generateId, ItemNode, - type WindowNode, } from '../schema' import { targetLevel } from './level-target' import type { AgentOperation } from './types' @@ -114,44 +112,15 @@ export const authorObject: AgentOperation = (nodes, input, co input.rotation === undefined ? undefined : [0, (input.rotation * Math.PI) / 180, 0] if (input.nodeId) { - // New code may also give a native window or door its script; params alone need one already. - const previous = input.code - ? scriptTarget(nodes, input.nodeId) - : authoredObject(nodes, 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)) - if (previous.type !== 'item') { - // 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 position: Vec3 = (input.position as Vec3 | undefined) ?? [ - x, - y - previous.height / 2 + height / 2, - z, - ] - return { - result: summary(previous, compiled, orphanedSlots), - changes: { - update: [ - { - id: previous.id, - data: { - name: input.name ?? previous.name, - source: scriptSource(compiled), - width, - height, - position, - }, - }, - ], - }, - } - } const next = ItemNode.parse({ ...previous, name: input.name ?? previous.name, @@ -214,6 +183,71 @@ export const authorObject: AgentOperation = (nodes, input, co } } +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 `read_source` and a params-only rebuild act on, or a refusal. */ export function authoredObject(nodes: Record, nodeId: string): ScriptedNode { const node = nodes[nodeId] @@ -227,16 +261,6 @@ export function authoredObject(nodes: Record, nodeId: string): return node } -/** What `author_object` with new code may edit: a scripted node, or a window or door taking its first script. */ -function scriptTarget( - nodes: Record, - nodeId: string, -): ScriptedNode | WindowNode | DoorNode { - const node = nodes[nodeId] - if (node?.type === 'window' || node?.type === 'door') return node - return authoredObject(nodes, nodeId) -} - /** What `read_source` answers once the host has the module's text. */ export function readSourceResult(node: ScriptedNode, code: string) { return { diff --git a/packages/core/src/agent-tools/author-object.ts b/packages/core/src/agent-tools/author-object.ts index a8d92d03c0..c62846ceab 100644 --- a/packages/core/src/agent-tools/author-object.ts +++ b/packages/core/src/agent-tools/author-object.ts @@ -17,12 +17,12 @@ Conventions (they make the object work in Pascal; follow them): - 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. A window or door opens with its open clip (the sash, the leaves): write one unless it is fixed. +- 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 (an object, or any window or door: code gives it a script, keeping its place and mark) with new code and/or params (params alone rebuild the stored script; read it first with read_source to change the code); identity, placement and paint are kept. The result lists the size, parts, slots, lights, animations and params.` +Edit: pass nodeId with new code and/or params (params alone rebuild the stored script; read it first with read_source to change the code); identity, placement and paint are kept. The result lists the size, parts, slots, lights, animations and params.` export const authorObjectTool = { name: 'author_object', @@ -74,7 +74,7 @@ export const readSourceTool = { name: 'read_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 an object's code, then pass the edited module to author_object with the same nodeId.", + '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 author_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/wall-openings.ts b/packages/core/src/agent-tools/wall-openings.ts index 152c85138d..e3cd014d68 100644 --- a/packages/core/src/agent-tools/wall-openings.ts +++ b/packages/core/src/agent-tools/wall-openings.ts @@ -58,7 +58,10 @@ export const addDoorTool = { 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. 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 (read_source first to change its code). Its native fields change with update_node.', + ), ...placement, width: measurement('length', 'm', { positive: true, @@ -93,7 +96,10 @@ export const addWindowTool = { 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. 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 (read_source first to change its code). Its native fields change with update_node.', + ), ...placement, width: measurement('length', 'm', { positive: true, diff --git a/packages/core/src/building/wall-openings.ts b/packages/core/src/building/wall-openings.ts index a11c22f565..6147373eb9 100644 --- a/packages/core/src/building/wall-openings.ts +++ b/packages/core/src/building/wall-openings.ts @@ -164,7 +164,7 @@ export function hasWallChildOverlap( export type WallOpeningInput = { kind: 'door' | 'window' - wallId: string + wallId?: string t?: number position?: number width?: number @@ -201,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') diff --git a/packages/editor/src/lib/geometry-script/author.ts b/packages/editor/src/lib/geometry-script/author.ts index abf21d23ab..fc996a54a7 100644 --- a/packages/editor/src/lib/geometry-script/author.ts +++ b/packages/editor/src/lib/geometry-script/author.ts @@ -6,7 +6,7 @@ import { getArtifactStore, useScene, } from '@pascal-app/core' -import { authoredObject, authorObject } from '@pascal-app/core/agent-operations' +import { authoredObject, authorObject, rescriptOpening } from '@pascal-app/core/agent-operations' import { compileGeometryScriptInWorker } from './client' /** @@ -56,11 +56,11 @@ export async function rebuildAuthoredObject( rebuildGeneration.set(nodeId, generation) const compiled = await compileAndStoreGeometryScript({ nodeId, params }) if (rebuildGeneration.get(nodeId) !== generation) return - const { changes } = authorObject( - useScene.getState().nodes, - { params, nodeId, compiled, position }, - { activeLevelId: null }, - ) + const nodes = useScene.getState().nodes + const opening = nodes[nodeId as AnyNodeId]?.type !== 'item' + const { changes } = opening + ? rescriptOpening(nodes, { nodeId, compiled, position }, { activeLevelId: null }) + : authorObject(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/mcp/src/tools/author-object.ts b/packages/mcp/src/tools/author-object.ts index f6c88743e2..4d17830839 100644 --- a/packages/mcp/src/tools/author-object.ts +++ b/packages/mcp/src/tools/author-object.ts @@ -68,7 +68,7 @@ export async function compileAndStore( return compiled } -async function readScript( +export async function readScript( host: GeometryScriptHost, sceneId: string, bridge: SceneOperations, diff --git a/packages/mcp/src/tools/room-tools.ts b/packages/mcp/src/tools/room-tools.ts index b18debcc19..9711004a6c 100644 --- a/packages/mcp/src/tools/room-tools.ts +++ b/packages/mcp/src/tools/room-tools.ts @@ -10,9 +10,10 @@ 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, @@ -27,7 +28,7 @@ import { z } from 'zod' import type { SceneOperations } from '../operations' import { ADDITIVE_TOOL_ANNOTATIONS, READ_ONLY_TOOL_ANNOTATIONS } from './annotations' import { findCatalogItem, searchCatalogItems } from './asset-catalog' -import { compileAndStore, type GeometryScriptHost } from './author-object' +import { compileAndStore, type GeometryScriptHost, readScript } from './author-object' import { ErrorCode, refusalResult, throwMcpError, toolError } from './errors' import { type LiveSyncStatus, @@ -37,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', @@ -539,6 +541,63 @@ export function registerCreateRoom(server: McpServer, bridge: SceneOperations): ) } +/** + * `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 author_object does, or the tool's error. */ async function compileOpeningScript( bridge: SceneOperations, @@ -581,6 +640,8 @@ export function registerAddDoor( 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 @@ -624,6 +685,8 @@ export function registerAddWindow( 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 diff --git a/wiki/architecture/authored-objects.md b/wiki/architecture/authored-objects.md index 9083002874..474dac37b3 100644 --- a/wiki/architecture/authored-objects.md +++ b/wiki/architecture/authored-objects.md @@ -8,7 +8,7 @@ Applies to: `packages/geometry-script/**`, `item.source` in `packages/core/src/s 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, `author_object` edits it by `nodeId` and `read_source` reads it. 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. +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 `read_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; `author_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). From 0b1adf963a72dd6259af2fc6ae96675ae548106e Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 2 Oct 2026 16:30:20 -0400 Subject: [PATCH 27/28] feat(geometry-script): scripts compile in an opaque-origin sandbox frame with a CSP Model-written code ran in a same-origin worker with network globals removed: a deterrent, not isolation. It now runs in a worker inside a hidden sandboxed iframe (allow-scripts only: opaque origin, no cookies, storage or access to the editor) whose CSP allows only its own scripts and blob workers; the worker inherits it, so the code cannot reach the network, load fonts or images, or open frames (checked: the same fetch succeeds without the CSP and fails with it). The compiler ships as one classic-script bundle (@pascal-app/geometry-script/sandbox, built by scripts/build-sandbox) that the frame starts from a blob, loaded only on the first compile. The editor hashes the returned GLB and the code itself; nothing the sandbox reports about itself is trusted. The same-origin worker and its window shim are gone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QUGvAXaawxPa5rnLSE53iZ --- .../editor/src/lib/geometry-script/client.ts | 137 +++++++++++++++--- .../geometry-script/geometry-script.worker.ts | 60 -------- .../lib/geometry-script/worker-window-shim.ts | 5 - packages/geometry-script/package.json | 9 +- .../geometry-script/scripts/build-sandbox.ts | 32 ++++ packages/geometry-script/src/compile.ts | 2 + .../geometry-script/src/sandbox-window.ts | 3 + .../geometry-script/src/sandbox-worker.ts | 58 ++++++++ 8 files changed, 215 insertions(+), 91 deletions(-) delete mode 100644 packages/editor/src/lib/geometry-script/geometry-script.worker.ts delete mode 100644 packages/editor/src/lib/geometry-script/worker-window-shim.ts create mode 100644 packages/geometry-script/scripts/build-sandbox.ts create mode 100644 packages/geometry-script/src/sandbox-window.ts create mode 100644 packages/geometry-script/src/sandbox-worker.ts diff --git a/packages/editor/src/lib/geometry-script/client.ts b/packages/editor/src/lib/geometry-script/client.ts index 8fef209e6c..5426b9d556 100644 --- a/packages/editor/src/lib/geometry-script/client.ts +++ b/packages/editor/src/lib/geometry-script/client.ts @@ -3,20 +3,53 @@ 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 } -let worker: Worker | null = null +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) { - worker?.terminate() - worker = null + frame?.remove() + frame = null + started = null for (const entry of pending.values()) { clearTimeout(entry.timeout) entry.reject(error) @@ -24,40 +57,96 @@ function reset(error: Error) { pending.clear() } -function getWorker(): Worker { - if (worker) return worker - if (typeof Worker === 'undefined') throw new Error('Web Workers are unavailable here') - worker = new Worker(new URL('./geometry-script.worker.ts', import.meta.url), { type: 'module' }) - worker.addEventListener('message', (event: MessageEvent) => { - const entry = pending.get(event.data.id) - if (!entry) return - pending.delete(event.data.id) - clearTimeout(entry.timeout) - if (event.data.ok) entry.resolve(event.data.output) - else entry.reject(new Error(event.data.error)) - }) - worker.addEventListener('error', (event) => { - reset(new Error(event.message || 'The geometry script worker crashed')) +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) }) - return worker +} + +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 worker and returns its GLB + manifest. A - * script that hangs is killed with its worker; the next compile starts fresh. + * 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 function compileGeometryScriptInWorker(input: { +export async function compileGeometryScriptInWorker(input: { code: string params?: Record }): Promise { - const active = getWorker() + 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, { resolve, reject, timeout }) - active.postMessage(request) + pending.set(id, { code: input.code, resolve, reject, timeout }) + target.postMessage(request, '*') }) } diff --git a/packages/editor/src/lib/geometry-script/geometry-script.worker.ts b/packages/editor/src/lib/geometry-script/geometry-script.worker.ts deleted file mode 100644 index d20b6c1144..0000000000 --- a/packages/editor/src/lib/geometry-script/geometry-script.worker.ts +++ /dev/null @@ -1,60 +0,0 @@ -/// -import './worker-window-shim' -// The compile subpath only: the package index pulls in core, which needs `window`. -import { compileGeometryScript } from '@pascal-app/geometry-script/compile' -import type { GeometryScriptWorkerRequest, GeometryScriptWorkerResponse } from './protocol' - -// Model-written code runs in this worker. Before any of it runs, remove the -// ambient capabilities a geometry build never needs: network, storage, -// nested workers and script loading. This is a deterrent, not a sandbox: -// the isolation boundary is an opaque-origin host with a CSP (not built yet). -const BLOCKED = [ - 'fetch', - 'XMLHttpRequest', - 'WebSocket', - 'WebTransport', - 'EventSource', - 'importScripts', - 'indexedDB', - 'caches', - 'Worker', - 'SharedWorker', - 'BroadcastChannel', - 'Request', - 'Response', -] as const - -const scope = self as unknown as Record -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 {} - } - } -} -try { - Object.defineProperty(scope.navigator as object, 'sendBeacon', { value: undefined }) -} catch {} - -self.addEventListener('message', async (event: MessageEvent) => { - const { id, code, params } = event.data - try { - const output = await compileGeometryScript({ code, params }) - const response: GeometryScriptWorkerResponse = { id, ok: true, output } - post(response, [output.glb]) - } catch (error) { - const response: GeometryScriptWorkerResponse = { - id, - ok: false, - error: error instanceof Error ? error.message : String(error), - } - post(response) - } -}) diff --git a/packages/editor/src/lib/geometry-script/worker-window-shim.ts b/packages/editor/src/lib/geometry-script/worker-window-shim.ts deleted file mode 100644 index 469649b5f1..0000000000 --- a/packages/editor/src/lib/geometry-script/worker-window-shim.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Turbopack compiles worker modules as browser code and folds three's -// `typeof window !== 'undefined'` guard to true, so `window` must exist before -// three evaluates. Imported first by the worker. -const scope = globalThis as { window?: unknown } -if (scope.window === undefined) scope.window = globalThis diff --git a/packages/geometry-script/package.json b/packages/geometry-script/package.json index c776ff3b46..11dffecd03 100644 --- a/packages/geometry-script/package.json +++ b/packages/geometry-script/package.json @@ -1,7 +1,7 @@ { "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 \u2014 no DOM, no React.", + "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", @@ -15,6 +15,11 @@ "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": [ @@ -22,7 +27,7 @@ "README.md" ], "scripts": { - "build": "tsc --build", + "build": "tsc --build && bun scripts/build-sandbox.ts", "dev": "tsgo --build --watch", "check-types": "tsgo --noEmit", "prepublishOnly": "npm run build", 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.ts b/packages/geometry-script/src/compile.ts index fcfe53b87d..5d355f06a0 100644 --- a/packages/geometry-script/src/compile.ts +++ b/packages/geometry-script/src/compile.ts @@ -695,6 +695,8 @@ function compactManifest(manifest: GeometryArtifactManifest): GeometryArtifactMa } 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('') } 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 }) From 079691c704e1293818a6eec8075f497eba472e21 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 2 Oct 2026 16:32:11 -0400 Subject: [PATCH 28/28] refactor(agent-tools): author_object becomes add_object, read_source becomes get_source The tools follow the inventory's verbs: add_ creates (and, with nodeId, rebuilds) a kind, get_* reads, update_node/delete_node edit any node. Both are unreleased, so the rename costs nothing outside this branch. Identifiers and files follow (addObjectTool, addObject, add-object.ts). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QUGvAXaawxPa5rnLSE53iZ --- .../{author-object.ts => add-object.ts} | 12 ++--- packages/core/src/agent-operations/index.ts | 2 +- .../{author-object.ts => add-object.ts} | 12 ++--- packages/core/src/agent-tools/find-by-type.ts | 2 +- packages/core/src/agent-tools/index.ts | 8 +-- .../core/src/agent-tools/wall-openings.ts | 6 +-- .../editor/src/lib/geometry-script/author.ts | 6 +-- packages/mcp/src/index.ts | 2 +- packages/mcp/src/server.ts | 4 +- .../tools/{author-object.ts => add-object.ts} | 45 +++++++--------- packages/mcp/src/tools/index.ts | 6 +-- packages/mcp/src/tools/place-item.ts | 2 +- .../src/tools/read-tool-annotations.test.ts | 4 +- packages/mcp/src/tools/room-tools.ts | 4 +- .../tool-annotation-justifications.json | 52 +++++++++---------- scripts/openai-tool-annotation-policy.ts | 4 +- skills/pascal-3d/SKILL.md | 2 +- wiki/architecture/authored-objects.md | 4 +- 18 files changed, 86 insertions(+), 91 deletions(-) rename packages/core/src/agent-operations/{author-object.ts => add-object.ts} (96%) rename packages/core/src/agent-tools/{author-object.ts => add-object.ts} (92%) rename packages/mcp/src/tools/{author-object.ts => add-object.ts} (84%) diff --git a/packages/core/src/agent-operations/author-object.ts b/packages/core/src/agent-operations/add-object.ts similarity index 96% rename from packages/core/src/agent-operations/author-object.ts rename to packages/core/src/agent-operations/add-object.ts index 151248a3e4..f5b31e9351 100644 --- a/packages/core/src/agent-operations/author-object.ts +++ b/packages/core/src/agent-operations/add-object.ts @@ -21,7 +21,7 @@ import type { AgentOperation } from './types' type Vec3 = [number, number, number] -export type AuthorObjectInput = { +export type AddObjectInput = { /** Absent for a params-only edit: the host compiled the object's stored script. */ code?: string params?: Record @@ -53,7 +53,7 @@ const HOSTS: Record = { function scriptAsset( compiled: CompiledGeometryScript, - input: AuthorObjectInput, + input: AddObjectInput, previous: ItemNode['asset'] | undefined, ): ItemNode['asset'] { const { min, max } = compiled.manifest.bounds @@ -100,13 +100,13 @@ function summary(node: { id: string }, compiled: CompiledGeometryScript, orphane } /** - * `author_object`: the item a compiled three.js module becomes. Not in + * `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 authorObject: AgentOperation = (nodes, input, context) => { +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] @@ -248,7 +248,7 @@ export const rescriptOpening: AgentOperation = (nodes, inp } } -/** The scripted node `read_source` and a params-only rebuild act on, or a refusal. */ +/** 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 }) @@ -261,7 +261,7 @@ export function authoredObject(nodes: Record, nodeId: string): return node } -/** What `read_source` answers once the host has the module's text. */ +/** What `get_source` answers once the host has the module's text. */ export function readSourceResult(node: ScriptedNode, code: string) { return { nodeId: node.id, diff --git a/packages/core/src/agent-operations/index.ts b/packages/core/src/agent-operations/index.ts index a415374812..7ae5f32762 100644 --- a/packages/core/src/agent-operations/index.ts +++ b/packages/core/src/agent-operations/index.ts @@ -6,8 +6,8 @@ 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 './author-object' export * from './delete-node' export * from './door-clearance' export * from './duplicate-level' diff --git a/packages/core/src/agent-tools/author-object.ts b/packages/core/src/agent-tools/add-object.ts similarity index 92% rename from packages/core/src/agent-tools/author-object.ts rename to packages/core/src/agent-tools/add-object.ts index c62846ceab..a541b1e947 100644 --- a/packages/core/src/agent-tools/author-object.ts +++ b/packages/core/src/agent-tools/add-object.ts @@ -22,10 +22,10 @@ Conventions (they make the object work in Pascal; follow them): - 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 read_source to change the code); identity, placement and paint are kept. The result lists the size, parts, slots, lights, animations and params.` +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 authorObjectTool = { - name: 'author_object', +export const addObjectTool = { + name: 'add_object', title: 'Build object', description: DESCRIPTION, input: { @@ -70,11 +70,11 @@ export const authorObjectTool = { }, } -export const readSourceTool = { - name: 'read_source', +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 author_object for an object, to add_window / add_door for a window or door.', + '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 index 674e5f118f..76655cb757 100644 --- a/packages/core/src/agent-tools/find-by-type.ts +++ b/packages/core/src/agent-tools/find-by-type.ts @@ -5,7 +5,7 @@ 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 author_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.", + "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() diff --git a/packages/core/src/agent-tools/index.ts b/packages/core/src/agent-tools/index.ts index 94e2c870fc..1d9bde8f0b 100644 --- a/packages/core/src/agent-tools/index.ts +++ b/packages/core/src/agent-tools/index.ts @@ -1,4 +1,4 @@ -import { authorObjectTool, readSourceTool } from './author-object' +import { addObjectTool, getSourceTool } from './add-object' import { findByTypeTool } from './find-by-type' import { duplicateLevelTool, @@ -11,7 +11,7 @@ import { import { deleteNodeTool, getNodeTool } from './nodes' import { addDoorTool, addWindowTool } from './wall-openings' -export * from './author-object' +export * from './add-object' export * from './find-by-type' export * from './levels' export * from './measurement' @@ -36,7 +36,7 @@ export const AGENT_TOOL_CONTRACTS = [ duplicateLevelTool, verifySceneTool, deleteNodeTool, - authorObjectTool, - readSourceTool, + 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 e3cd014d68..6914637799 100644 --- a/packages/core/src/agent-tools/wall-openings.ts +++ b/packages/core/src/agent-tools/wall-openings.ts @@ -27,7 +27,7 @@ const script = (kind: string) => ({ .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 author_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.`, + `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()])) @@ -60,7 +60,7 @@ export const addDoorTool = { input: { 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 (read_source first to change its code). Its native fields change with update_node.', + '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', { @@ -98,7 +98,7 @@ export const addWindowTool = { input: { 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 (read_source first to change its code). Its native fields change with update_node.', + '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', { diff --git a/packages/editor/src/lib/geometry-script/author.ts b/packages/editor/src/lib/geometry-script/author.ts index fc996a54a7..a0597ced3e 100644 --- a/packages/editor/src/lib/geometry-script/author.ts +++ b/packages/editor/src/lib/geometry-script/author.ts @@ -6,11 +6,11 @@ import { getArtifactStore, useScene, } from '@pascal-app/core' -import { authoredObject, authorObject, rescriptOpening } from '@pascal-app/core/agent-operations' +import { addObject, authoredObject, rescriptOpening } from '@pascal-app/core/agent-operations' import { compileGeometryScriptInWorker } from './client' /** - * The editor's compile step for `author_object`: runs the module in the + * 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. @@ -60,7 +60,7 @@ export async function rebuildAuthoredObject( const opening = nodes[nodeId as AnyNodeId]?.type !== 'item' const { changes } = opening ? rescriptOpening(nodes, { nodeId, compiled, position }, { activeLevelId: null }) - : authorObject(nodes, { params, 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/mcp/src/index.ts b/packages/mcp/src/index.ts index 982eab8c30..c31f5d59e2 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -1,5 +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/author-object' +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 921617667e..0b8adaf8d3 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -5,7 +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/author-object' +import type { GeometryScriptHost } from './tools/add-object' import { normalizeToolSchemaDialect } from './tools/normalize-schema-dialect' import { registerVisionTools } from './tools/vision' import { version } from './version' @@ -29,7 +29,7 @@ export type CreatePascalMcpServerOptions = { * Experimental task-based tool registrations are outside this hook. */ executeTool?: PascalMcpToolExecutor - /** Runs and stores `author_object` modules; without it the tool answers `scripts_unavailable`. */ + /** Runs and stores `add_object` modules; without it the tool answers `scripts_unavailable`. */ geometryScripts?: GeometryScriptHost } diff --git a/packages/mcp/src/tools/author-object.ts b/packages/mcp/src/tools/add-object.ts similarity index 84% rename from packages/mcp/src/tools/author-object.ts rename to packages/mcp/src/tools/add-object.ts index 4d17830839..0a3b605321 100644 --- a/packages/mcp/src/tools/author-object.ts +++ b/packages/mcp/src/tools/add-object.ts @@ -1,16 +1,11 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { - type AuthorObjectInput, + type AddObjectInput, + addObject, authoredObject, - authorObject, readSourceResult, } from '@pascal-app/core/agent-operations' -import { - authorObjectTool, - isAgentRefusal, - readSourceTool, - refuse, -} from '@pascal-app/core/agent-tools' +import { addObjectTool, getSourceTool, isAgentRefusal, refuse } from '@pascal-app/core/agent-tools' import { type AnyNode, type CompiledGeometryScript, @@ -24,7 +19,7 @@ import { persistencePayload, publishLiveSceneSnapshot } from './live-sync' import { toPatches } from './shared-tools' /** - * How a host runs `author_object`'s module and keeps the result. Running + * 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. */ @@ -80,18 +75,18 @@ export async function readScript( return new TextDecoder().decode(bytes) } -/** `author_object` on the MCP: the shared contract and operation, with the host's compile in front. */ -export function registerAuthorObject( +/** `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( - authorObjectTool.name, + addObjectTool.name, { - title: authorObjectTool.title, - description: authorObjectTool.description, - inputSchema: authorObjectTool.input, + title: addObjectTool.title, + description: addObjectTool.description, + inputSchema: addObjectTool.input, annotations: DESTRUCTIVE_TOOL_ANNOTATIONS, }, async (input: Record) => { @@ -106,7 +101,7 @@ export function registerAuthorObject( code: 'no_active_scene', }) } - const args = input as Omit + const args = input as Omit let compiled: CompiledGeometryScript try { const code = @@ -121,9 +116,9 @@ export function registerAuthorObject( code: 'script_failed', }) } - let outcome: ReturnType + let outcome: ReturnType try { - outcome = authorObject( + outcome = addObject( bridge.getNodes() as Record, { ...args, compiled }, { activeLevelId: null }, @@ -135,7 +130,7 @@ export function registerAuthorObject( if (patches.length) bridge.applyPatch(patches) const payload = { ...outcome.result, - ...persistencePayload(await publishLiveSceneSnapshot(bridge, authorObjectTool.name)), + ...persistencePayload(await publishLiveSceneSnapshot(bridge, addObjectTool.name)), } return { content: [{ type: 'text' as const, text: JSON.stringify(payload) }], @@ -152,18 +147,18 @@ function refuseMissingCode(): never { ) } -/** `read_source` on the MCP: the object's module text, read back through the host's store. */ -export function registerReadSource( +/** `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( - readSourceTool.name, + getSourceTool.name, { - title: readSourceTool.title, - description: readSourceTool.description, - inputSchema: readSourceTool.input, + title: getSourceTool.title, + description: getSourceTool.description, + inputSchema: getSourceTool.input, annotations: READ_ONLY_TOOL_ANNOTATIONS, }, async ({ nodeId }: { nodeId: string }) => { diff --git a/packages/mcp/src/tools/index.ts b/packages/mcp/src/tools/index.ts index 80c5d5b832..742994b29d 100644 --- a/packages/mcp/src/tools/index.ts +++ b/packages/mcp/src/tools/index.ts @@ -1,7 +1,7 @@ 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 { type GeometryScriptHost, registerAuthorObject, registerReadSource } from './author-object' import { registerCheckCollisions } from './check-collisions' import { registerConstructionTools } from './construction-tools' import { registerCreateLevel } from './create-level' @@ -48,8 +48,8 @@ export function registerTools( registerDescribeNode(server, operations) registerFindNodes(server, operations) registerSharedTools(server, operations) - registerAuthorObject(server, operations, geometryScripts) - registerReadSource(server, operations, geometryScripts) + registerAddObject(server, operations, geometryScripts) + registerGetSource(server, operations, geometryScripts) registerMeasure(server, operations) registerConstructionTools(server, operations) registerRoomTools(server, operations, geometryScripts) diff --git a/packages/mcp/src/tools/place-item.ts b/packages/mcp/src/tools/place-item.ts index 5f544e1efb..8cd920c3ff 100644 --- a/packages/mcp/src/tools/place-item.ts +++ b/packages/mcp/src/tools/place-item.ts @@ -37,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, a ceiling for ceiling-attached items, or an item to rest on it (position in level coordinates; on an object built with author_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.', + '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, diff --git a/packages/mcp/src/tools/read-tool-annotations.test.ts b/packages/mcp/src/tools/read-tool-annotations.test.ts index 17436dd4a5..906d2d29d6 100644 --- a/packages/mcp/src/tools/read-tool-annotations.test.ts +++ b/packages/mcp/src/tools/read-tool-annotations.test.ts @@ -33,7 +33,7 @@ const TOOL_POLICIES = [ 'list_templates', 'list_units', 'measure', - 'read_source', + 'get_source', 'search_assets', 'validate_design', 'validate_scene', @@ -87,7 +87,7 @@ const TOOL_POLICIES = [ }, tools: [ 'apply_patch', - 'author_object', + 'add_object', 'create_from_template', 'create_house_from_brief', 'create_stair_between_levels', diff --git a/packages/mcp/src/tools/room-tools.ts b/packages/mcp/src/tools/room-tools.ts index 9711004a6c..d7e727e5bd 100644 --- a/packages/mcp/src/tools/room-tools.ts +++ b/packages/mcp/src/tools/room-tools.ts @@ -26,9 +26,9 @@ import type { 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 { compileAndStore, type GeometryScriptHost, readScript } from './author-object' import { ErrorCode, refusalResult, throwMcpError, toolError } from './errors' import { type LiveSyncStatus, @@ -598,7 +598,7 @@ async function rebuildOpening( }) } -/** A door or window passed `code`: compiled and stored the way author_object does, or the tool's error. */ +/** 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, diff --git a/plugin-evals/tool-annotation-justifications.json b/plugin-evals/tool-annotation-justifications.json index 39e567aae8..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": { @@ -71,19 +84,6 @@ "openWorldHint": "It operates on the connected Pascal scene or project service without accessing the public internet." } }, - { - "name": "author_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": "check_collisions", "annotations": { @@ -487,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": { @@ -669,19 +682,6 @@ "openWorldHint": "It operates on the connected Pascal scene, project, and configured catalog data without accessing the public internet." } }, - { - "name": "read_source", - "annotations": { - "readOnlyHint": true, - "destructiveHint": false, - "openWorldHint": false - }, - "justifications": { - "readOnlyHint": "It only reads the script stored for an object built with author_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": "rebase_floor_reference", "annotations": { diff --git a/scripts/openai-tool-annotation-policy.ts b/scripts/openai-tool-annotation-policy.ts index 3c2adf77f3..9bf524b6c6 100644 --- a/scripts/openai-tool-annotation-policy.ts +++ b/scripts/openai-tool-annotation-policy.ts @@ -20,8 +20,8 @@ export const EXPECTED_OPENAI_TOOL_ANNOTATIONS = { analyze_room_photo: policy(true, false, true), apply_patch: policy(false, true, false), find_by_type: policy(true, false, false), - read_source: policy(true, false, false), - author_object: policy(false, true, 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 b23415124e..141a7c404b 100644 --- a/skills/pascal-3d/SKILL.md +++ b/skills/pascal-3d/SKILL.md @@ -57,7 +57,7 @@ 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 `author_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`. +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. diff --git a/wiki/architecture/authored-objects.md b/wiki/architecture/authored-objects.md index 474dac37b3..f1bfc455f5 100644 --- a/wiki/architecture/authored-objects.md +++ b/wiki/architecture/authored-objects.md @@ -8,7 +8,7 @@ Applies to: `packages/geometry-script/**`, `item.source` in `packages/core/src/s 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 `read_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; `author_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. +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). @@ -43,4 +43,4 @@ The compiler also derives upward surfaces (where things rest) and undersides (wh ## Agent tools -`author_object` (create, or edit by `nodeId`: new code, or params alone to rebuild the stored script), `read_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). +`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).