Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
34ead4e
feat(geometry-script): items authored as three.js scripts, compiled t…
wass08 Oct 1, 2026
b81990e
fix(geometry-script): worker loads three under Turbopack; compile-onl…
wass08 Oct 1, 2026
fb0dd73
feat(agent-tools): author_object is a shared chat/MCP tool; typed par…
wass08 Oct 2, 2026
dcfe86d
feat(placement): objects land on an authored object's real surfaces
wass08 Oct 2, 2026
a74515b
perf(geometry-script): one mesh per part and material; lit items stil…
wass08 Oct 2, 2026
9a391e4
feat(geometry-script): authored objects animate with their own three.…
wass08 Oct 2, 2026
6b38e85
feat(item): every authored clip gets a control and reaches the GLB ex…
wass08 Oct 2, 2026
c445781
fix(geometry-script): sample clips to transform tracks so any motion …
wass08 Oct 2, 2026
df88378
feat(item): a Parameters section for authored objects
wass08 Oct 2, 2026
3f25474
fix(authored objects): no parts cap; the panel shows a plain error
wass08 Oct 2, 2026
ec62d1c
feat(hosting): ceiling items hang from an authored object's undersides
wass08 Oct 2, 2026
d630fa3
feat(item): X/Z rotation in the panel; flush fixtures tilt with slope…
wass08 Oct 2, 2026
5b16a25
feat(agent-tools): find_by_type — nodes and authored parts of one type
wass08 Oct 2, 2026
9348a99
feat(wall): an authored object's cutout cuts its real shape
wass08 Oct 2, 2026
ac7b8e8
feat(authored objects): children re-settle when the object is rebuilt
wass08 Oct 2, 2026
41fcfb2
chore(geometry-script): release, lockfile, skill and wiki for authore…
wass08 Oct 2, 2026
042d242
fix(core): classify authored-manifest keys; keep a literal three impo…
wass08 Oct 2, 2026
e30f17f
fix(agent-tools): shaped doors and windows go through author_object
wass08 Oct 2, 2026
15f11ec
feat(agent-tools): add_window/add_door take the outline, type and pan…
wass08 Oct 2, 2026
56a1bd8
refactor(author_object): no refusal for doors and windows; the descri…
wass08 Oct 2, 2026
588542b
feat(authored objects): the script is stored by hash, never in the sc…
wass08 Oct 2, 2026
3fd2614
feat(openings): windows and doors built from a script, through the it…
wass08 Oct 2, 2026
77bc1ff
fix(geometry-script): clips are stored ending on their last pose; aut…
wass08 Oct 2, 2026
ad3ec29
feat(openings): scripted openings show only the handles their params …
wass08 Oct 2, 2026
63df9f1
fix(openings): scripted openings export their clips, place the toolba…
wass08 Oct 2, 2026
ff8b3de
feat(openings): add_window/add_door rebuild an opening by nodeId; aut…
wass08 Oct 2, 2026
0b1adf9
feat(geometry-script): scripts compile in an opaque-origin sandbox fr…
wass08 Oct 2, 2026
079691c
refactor(agent-tools): author_object becomes add_object, read_source …
wass08 Oct 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: |
Expand Down Expand Up @@ -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
Expand Down
22 changes: 21 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

276 changes: 276 additions & 0 deletions packages/core/src/agent-operations/add-object.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
import { refuse } from '../agent-tools/refusal'
import { artifactUrl } from '../lib/artifact-store'
import {
isScriptedNode,
type ScriptedNode,
scriptedSize,
scriptInteractive,
scriptSource,
} from '../lib/geometry-script-node'
import { geometryRestingHeight, resettledPosition } from '../lib/geometry-surfaces'
import {
type AnyNode,
type CompiledGeometryScript,
type GeometryScriptMount,
type GeometryScriptParamValue,
generateId,
ItemNode,
} from '../schema'
import { targetLevel } from './level-target'
import type { AgentOperation } from './types'

type Vec3 = [number, number, number]

export type AddObjectInput = {
/** Absent for a params-only edit: the host compiled the object's stored script. */
code?: string
params?: Record<string, GeometryScriptParamValue>
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<GeometryScriptMount, ItemNode['asset']['attachTo']> = {
floor: undefined,
wall: 'wall',
'wall-side': 'wall-side',
ceiling: 'ceiling',
}

const HOSTS: Record<GeometryScriptMount, readonly AnyNode['type'][]> = {
floor: ['level', 'item'],
wall: ['wall'],
'wall-side': ['wall'],
ceiling: ['ceiling'],
}

function scriptAsset(
compiled: CompiledGeometryScript,
input: AddObjectInput,
previous: ItemNode['asset'] | undefined,
): ItemNode['asset'] {
const { min, max } = compiled.manifest.bounds
const restingHeight = geometryRestingHeight(compiled.manifest)
return {
id: `script_${compiled.sha256.slice(0, 16)}`,
category: input.category ?? previous?.category ?? 'object',
name: input.name ?? previous?.name ?? 'Authored object',
thumbnail: previous?.thumbnail ?? '',
source: 'mine',
src: artifactUrl(compiled.sha256),
dimensions: [max[0] - min[0], max[1] - min[1], max[2] - min[2]],
attachTo: ATTACH[compiled.mount],
surface: restingHeight === null ? undefined : { height: restingHeight },
offset: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
interactive: scriptInteractive(compiled.manifest),
}
}

const round = (value: number) => Math.round(value * 1000) / 1000

function summary(node: { id: string }, compiled: CompiledGeometryScript, orphanedSlots: string[]) {
const { bounds, parts, slots, lights, params, triangles, cutout, animations } = compiled.manifest
return {
nodeId: node.id,
mount: compiled.mount,
size: bounds.max.map((v, i) => round(v - bounds.min[i]!)),
parts: parts.map((part) => (part.type ? `${part.id} (${part.type})` : part.id)),
slots: slots.map((slot) => slot.id),
lights: lights.map((light) => light.id),
animations: animations.map((clip) => clip.name),
params: params.map((spec) => ({ ...spec, value: compiled.params[spec.id] })),
cutout,
triangles,
...(orphanedSlots.length > 0
? {
orphanedSlots,
note: `Paint on ${orphanedSlots.join(', ')} is kept but no longer shows: the new output has no slot with that id.`,
}
: {}),
}
}

/**
* `add_object`: the item a compiled three.js module becomes. Not in
* AGENT_OPERATIONS: each surface compiles `code` first (the chat in its
* worker, the MCP on the server) and passes the result as `compiled`.
* The artifact is referenced by hash and its bounds become the item's dimensions; editing
* keeps the item's identity, placement, children and paint.
*/
export const addObject: AgentOperation<AddObjectInput> = (nodes, input, context) => {
const { compiled } = input
const rotation: Vec3 | undefined =
input.rotation === undefined ? undefined : [0, (input.rotation * Math.PI) / 180, 0]

if (input.nodeId) {
const previous = authoredObject(nodes, input.nodeId)
if (previous.type !== 'item')
refuse(
'use_opening_tool',
`${previous.id} is a ${previous.type}: rebuild it with add_${previous.type} and nodeId.`,
{ id: previous.id, type: previous.type },
)
const slotIds = new Set(compiled.manifest.slots.map((slot) => slot.id))
const orphanedSlots = Object.keys(previous.slots ?? {}).filter((id) => !slotIds.has(id))
const next = ItemNode.parse({
...previous,
name: input.name ?? previous.name,
position: (input.position as Vec3 | undefined) ?? previous.position,
rotation: rotation ?? previous.rotation,
side: input.side ?? previous.side,
source: scriptSource(compiled),
asset: scriptAsset(compiled, input, previous.asset),
})
// Children resting on or hanging from the object follow its new geometry.
const resettled: { id: string; position: Vec3 }[] = []
for (const childId of previous.children) {
const child = nodes[childId]
if (child?.type !== 'item' || child.wallId) continue
const position = resettledPosition(compiled.manifest, child, next.scale)
if (!position || position.every((v, i) => Math.abs(v - child.position[i]!) < 1e-4)) continue
resettled.push({ id: child.id, position })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rebuild drops fixture tilt

Medium Severity

A rebuild only writes resettled position. Flush ceiling children (recessed cans) keep their old Euler tilt while resettledPosition moves them onto the new underside. Changing a vault or tray slope leaves those fixtures hanging at the previous angle.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 042d242. Configure here.

}
return {
result: {
...summary(next, compiled, orphanedSlots),
...(resettled.length > 0 ? { resettled: resettled.map((entry) => entry.id) } : {}),
},
changes: {
update: [
{ id: next.id, data: next },
...resettled.map(({ id, position }) => ({ id, data: { position } })),
],
},
}
}

const parent = input.parentId ? nodes[input.parentId] : targetLevel(nodes, {}, context)
if (!parent)
refuse('node_not_found', `Node not found: ${input.parentId}.`, { id: input.parentId })
const hosts = HOSTS[compiled.mount]
if (!hosts.includes(parent.type)) {
refuse(
'wrong_host',
`A ${compiled.mount} object goes on a ${hosts.join(' or ')}, not on a ${parent.type}. Pass parentId of a ${hosts[0]}, or change \`mount\`.`,
{ mount: compiled.mount, parentType: parent.type },
)
}
const asset = scriptAsset(compiled, input, undefined)
const node = ItemNode.parse({
object: 'node',
id: generateId('item'),
type: 'item',
name: input.name ?? asset.name,
parentId: parent.id,
...(parent.type === 'wall' ? { wallId: parent.id, side: input.side ?? 'front' } : {}),
position: (input.position as Vec3 | undefined) ?? [0, 0, 0],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Item host skips surface snap

Medium Severity

author_object accepts an item parent for floor mounts, but create always uses the given position or [0, 0, 0]. It never lands the new object on the host's resting surface, unlike place_item and rebuild resettle.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e30f17f. Configure here.

rotation: rotation ?? [0, 0, 0],
source: scriptSource(compiled),
asset,
})
return {
result: summary(node, compiled, []),
changes: { create: [{ node, parentId: parent.id }] },
}
}

export type RescriptOpeningInput = {
nodeId: string
/** Where it goes; without one its bottom edge stays put. */
position?: number[]
name?: string
/** What the host compiled: new code, or the stored script with new params. */
compiled: CompiledGeometryScript
}

/**
* `add_window` / `add_door` with a nodeId: a window or door built from (or
* given) a script, rebuilt from what the host compiled. Its size is what the
* script built; marks, hosting and the opening's own fields are kept.
*/
export const rescriptOpening: AgentOperation<RescriptOpeningInput> = (nodes, input) => {
const { compiled } = input
const previous = nodes[input.nodeId]
if (!previous) refuse('node_not_found', `Node not found: ${input.nodeId}.`, { id: input.nodeId })
if (previous.type !== 'window' && previous.type !== 'door')
refuse('not_an_opening', `${input.nodeId} is a ${previous.type}, not a window or door.`, {
id: input.nodeId,
type: previous.type,
})
const slotIds = new Set(compiled.manifest.slots.map((slot) => slot.id))
const orphanedSlots = Object.keys(previous.slots ?? {}).filter((id) => !slotIds.has(id))
// A window or door keeps its place on the wall and its bottom edge; its size is what the script built.
if (compiled.mount !== 'wall')
refuse('wrong_mount', `A ${previous.type}'s script uses mount 'wall'.`, {
mount: compiled.mount,
})
const [width, height] = scriptedSize(compiled.manifest)
// Given a position, that is where it goes; otherwise its bottom edge stays put.
const [x, y, z] = previous.position
const placed: Vec3 = (input.position as Vec3 | undefined) ?? [
x,
y - previous.height / 2 + height / 2,
z,
]
// A wider rebuild stays on its wall, as a new opening does.
const wall = previous.wallId ? nodes[previous.wallId] : undefined
const wallLength =
wall?.type === 'wall' ? Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) : 0
const position: Vec3 =
wallLength >= width
? [Math.min(wallLength - width / 2, Math.max(width / 2, placed[0])), placed[1], placed[2]]
: placed
return {
result: summary(previous, compiled, orphanedSlots),
changes: {
update: [
{
id: previous.id,
data: {
name: input.name ?? previous.name,
source: scriptSource(compiled),
width,
height,
position,
},
},
],
},
}
}

/** The scripted node `get_source` and a params-only rebuild act on, or a refusal. */
export function authoredObject(nodes: Record<string, AnyNode>, nodeId: string): ScriptedNode {
const node = nodes[nodeId]
if (!node) refuse('node_not_found', `Node not found: ${nodeId}.`, { id: nodeId })
if (!isScriptedNode(node))
refuse(
'not_authored',
`${nodeId} is a ${node.type} without a script; only objects, windows and doors built from code have one.`,
{ id: nodeId, type: node.type },
)
return node
}

/** What `get_source` answers once the host has the module's text. */
export function readSourceResult(node: ScriptedNode, code: string) {
return {
nodeId: node.id,
type: node.type,
name: node.name,
code,
params: node.source.manifest.params.map((spec) => ({
...spec,
value: node.source.params[spec.id] ?? spec.default,
})),
}
}
Loading
Loading