Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
cdd7346
chore(editor): pin local plugins to package snapshots
sudhir9297 Oct 6, 2026
9a1068e
feat(editor): integrate local plugins and deck railing workflows
sudhir9297 Oct 6, 2026
ebfce89
fix(editor): address plugin integration architecture review
sudhir9297 Oct 6, 2026
24e2fb0
fix(ci): use portable plugin pins and opt-in local development links
sudhir9297 Oct 6, 2026
6dc1ed6
fix(ci): install Landscape vendor and Clipper declarations
sudhir9297 Oct 6, 2026
13bdd43
fix(core): classify the room boundary metadata flag
sudhir9297 Oct 6, 2026
d49b39f
test(nodes): exercise stair snapping in the structural move context
sudhir9297 Oct 6, 2026
b914db6
docs: clarify restoring plugin pins before portable builds
sudhir9297 Oct 6, 2026
1e1e3b7
fix(cli): budget the portable runtime for integrated plugin assets
sudhir9297 Oct 6, 2026
4c2a4bb
fix(editor): preserve drawing defaults and wall grab offsets
sudhir9297 Oct 6, 2026
f581e3e
fix(editor): separate snap yaw and reset grabs on host transitions
sudhir9297 Oct 6, 2026
838597f
fix(editor): forget wall grabs when entering another host
sudhir9297 Oct 6, 2026
b7c9123
fix(editor): retain tall stair attachment and wall grabs through floo…
sudhir9297 Oct 6, 2026
05cde6d
fix(editor): integrate surface workflows with upstream fence and stai…
sudhir9297 Oct 7, 2026
af65ad1
fix(ci): pin WebXR reconciliation API update
sudhir9297 Oct 7, 2026
c88ce92
fix(stair): resolve live segments for floorplan documentation
sudhir9297 Oct 7, 2026
8ed2b2d
fix(editor): address plugin and surface workflow architecture review
sudhir9297 Oct 7, 2026
7601a7b
fix(ci): stabilize stair regressions and bound room vertex scans
sudhir9297 Oct 7, 2026
bd0679d
chore(plugins): replace local links with current GitHub revisions
sudhir9297 Oct 9, 2026
89d50de
fix(editor): integrate plugin phases after main rebase
sudhir9297 Oct 9, 2026
22272a4
chore(plugins): pin committed Bath Space guided UI subtree
sudhir9297 Oct 13, 2026
a1fc427
fix(editor): resolve GitHub plugins from installed packages
sudhir9297 Oct 13, 2026
3536afd
fix(editor): preserve plugin autosave and simplify editing UI
sudhir9297 Oct 13, 2026
5fdaf6d
test(editor): cover collapsed and active HUD shortcuts
sudhir9297 Oct 13, 2026
16d45db
feat(editor): pin Bath Space category picker and support local develo…
sudhir9297 Oct 14, 2026
b8f7aaf
chore(editor): remove local Bath Space development override
sudhir9297 Oct 14, 2026
0b51836
Merge main into local plugins, surface stairs and deck railings
wass08 Oct 15, 2026
eb773a2
Keep saved streetscape scenes, the stair height fix and plain fences
wass08 Oct 15, 2026
4fb48d9
Pin streetscape to pascalorg/plugin-streetscape
wass08 Oct 15, 2026
0dd92df
fix(editor): address plugin architecture and surface workflow reviews
sudhir9297 Oct 16, 2026
16091ac
fix(cli): preserve runtime activation and shrink offline catalogs
sudhir9297 Oct 16, 2026
046f962
fix(editor): pin shader texture type fixes
sudhir9297 Oct 16, 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
13 changes: 13 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,19 @@ Each entry: decision · why · enforced by · revisit when. Detail and examples

**E-016 Plugins own their agent tools (owner, 2026-10-10).** A plugin's agent tools ship with the plugin, not in core: contracts as a `PluginAgentTools` in its `./agent-tools` export (zod only, like `core/agent-tools`, and versioned by the plugin), pure operations as an `AgentOperations` in `./agent-operations`, browser execution and cards as a `PluginChatExtension`. The `Plugin` manifest does not change (E-014), and one contract still serves both surfaces. Contract annotations are hints: the host decides which tools are offered, to whom, and with what authority. These are reviewed, trusted contributions, not a sandbox. · Why: a plugin with dozens of specialist tools keeps them versioned with its own code, and installing it extends every agent the way a connector does. · Enforced: `core/src/agent-tools/contracts-purity.test.ts` (the rule each plugin entry follows), `mcp/src/tools/shared-tools.test.ts` (a plugin's operation tool through a real client), the hosted parity test. · See `plugin-authoring.md`, `agent-surfaces.md`. · Revisit: when plugins are loaded without review.

**E-017 Additive node editing and surface contracts (2026-10-16).** Surface boundaries,
connected movement, primary inspector predicates, independent child movement and
preserved level/elevation are optional contributions on `NodeDefinition` and its
existing capability types. They do not add fields to the v1 `Plugin` manifest.
Boundary providers own their geometry and coordinates; framework consumers dispatch
through the registry. Connected-move providers return pure patches, previewed with
live overrides and committed once per gesture. Inspector predicates select an ID
without subscribing the card to whole nodes. `metadata.roomBoundary === false` is
the explicit opt-out from room topology. Shared run-authoring tools are exported by
`editor/distribution`, never by the built-in plugin. · Why: external node packs need
the same authoring behavior without depending on built-in kinds or private plugin
shapes. · Contract: `wiki/architecture/plugin-authoring.md`.

## Testing

**T-001 (2026-10-01, owner).** A test protects an observable result, a persisted or wire contract, or an explicit architecture/performance invariant — and tolerates an equivalent implementation. Therefore: test through package exports; no source-grep, prose-pinning or import-spelling tests (use Biome rules); no cross-package `src/` imports in tests; no `mock.module` that forces sibling files to re-spawn `bun test`; no real sleeps (inject a clock); no tests named after review rounds or bots; refactor scaffolding (parity tables, "matches main" goldens, frozen copies of old code) is deleted in the PR that lands the refactor; performance claims live in `__bench__` / bench lanes, not unit gates; a bug fix is one repro in the feature's existing file, not a new `*-audit*` file; a test that needs an opt-in flag must have a lane that sets it, or go. "Internal" is not the deletion criterion: core's import boundary, the writers allowlist and the migration thresholds pin internals with external consequences and stay. · Why: 231k test lines (82k of them fixtures) had become the main cost of every change for people and agents alike. · Enforced: review; Biome rules as they land.
Expand Down
26 changes: 26 additions & 0 deletions apps/editor/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@
@source "../../../node_modules/@pascal-app/plugin-streetscape/src";
@source "../../../node_modules/@pascal-app/plugin-environment/src";

@source "../../../node_modules/@pascal-app/plugin-landscape/src";
@source "../../../node_modules/@pascal-app/plugin-bath-space/src";
@source "../../../node_modules/@pascal-app/plugin-pool/src";
@source "../../../node_modules/@webxr/plugin/src";

@custom-variant dark (&:is(.dark *));

@theme {
Expand Down Expand Up @@ -171,6 +176,27 @@
scrollbar-width: none; /* Firefox */
}

* {
scrollbar-width: thin;
scrollbar-color: gray transparent;
}

*::-webkit-scrollbar {
width: 8px;
height: 8px;
background: transparent;
}

*::-webkit-scrollbar-track,
*::-webkit-scrollbar-track-piece,
*::-webkit-scrollbar-corner {
background: transparent;
}

*::-webkit-scrollbar-thumb {
background-color: gray;
}

@media (prefers-reduced-motion: reduce) {
*,
*::before,
Expand Down
18 changes: 18 additions & 0 deletions apps/editor/components/pool-section-banner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
'use client'

import { lazy, Suspense } from 'react'

const PoolSectionBar = lazy(async () => {
const pool = await import('@pascal-app/plugin-pool')
return { default: pool.PoolSectionBar }
})

export function PoolSectionBanner() {
return (
<div data-pool-section-global>
<Suspense fallback={null}>
<PoolSectionBar />
</Suspense>
</div>
)
}
24 changes: 20 additions & 4 deletions apps/editor/components/scene-loader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,14 @@ import { Hammer, Layers, Package, Palette, Settings } from 'lucide-react'
import Image from 'next/image'
import { useRouter, useSearchParams } from 'next/navigation'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { PoolSectionBanner } from '@/components/pool-section-banner'
import { countGraphNodes, isEmptyGraphOverwrite } from '@/lib/empty-graph-guard'
import { useLocalArtifactStore } from '@/lib/local-artifact-store'
import { type PersistedSceneGraph, sceneGraphSignature } from '@/lib/scene-signature'
import {
isPendingSceneGraphEcho,
type PersistedSceneGraph,
sceneGraphSignature,
} from '@/lib/scene-signature'
import { useThumbnailAutosave } from '@/lib/use-thumbnail-autosave'
import { BuildTab } from './build-tab'
import { EditorNavbar } from './editor-navbar'
Expand Down Expand Up @@ -187,6 +192,7 @@ function SceneEditor({ initialScene, meta: initialMeta }: SceneLoaderProps) {
// store must never overwrite a populated server copy.
const serverNodeCountRef = useRef(meta.nodeCount)
const lastRemoteGraphJsonRef = useRef<string | null>(null)
const pendingGraphSignaturesRef = useRef(new Set<string>())
const suppressRemoteSaveUntilRef = useRef(0)
const [conflict, setConflict] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
Expand Down Expand Up @@ -249,6 +255,7 @@ function SceneEditor({ initialScene, meta: initialMeta }: SceneLoaderProps) {
return
}

pendingGraphSignaturesRef.current.add(graphJson)
try {
const response = await fetch(`/api/scenes/${meta.id}`, {
method: 'PUT',
Expand Down Expand Up @@ -285,11 +292,15 @@ function SceneEditor({ initialScene, meta: initialMeta }: SceneLoaderProps) {
}

const next = (await response.json()) as SceneMeta
versionRef.current = next.version
serverNodeCountRef.current = next.nodeCount
if (next.version >= versionRef.current) {
versionRef.current = next.version
serverNodeCountRef.current = next.nodeCount
}
setSaveError(null)
} catch (error) {
setSaveError(error instanceof Error ? error.message : 'Save failed')
} finally {
pendingGraphSignaturesRef.current.delete(graphJson)
}
},
[meta.id, meta.name],
Expand All @@ -310,7 +321,11 @@ function SceneEditor({ initialScene, meta: initialMeta }: SceneLoaderProps) {

versionRef.current = payload.version
serverNodeCountRef.current = countGraphNodes(payload.graph)
lastRemoteGraphJsonRef.current = sceneGraphSignature(payload.graph)
const signature = sceneGraphSignature(payload.graph)
// SSE can deliver our save before its HTTP response. Reapplying that
// graph would reset tools and selection while the user keeps editing.
if (isPendingSceneGraphEcho(payload.graph, pendingGraphSignaturesRef.current)) return
lastRemoteGraphJsonRef.current = signature
Comment thread
cursor[bot] marked this conversation as resolved.
suppressRemoteSaveUntilRef.current = Date.now() + 2500
applySceneGraphToEditor(payload.graph)
setConflict(false)
Expand Down Expand Up @@ -381,6 +396,7 @@ function SceneEditor({ initialScene, meta: initialMeta }: SceneLoaderProps) {
projectId={meta.projectId ?? 'default'}
projectName={meta.name}
sidebarTabs={SIDEBAR_TABS}
viewerBanner={<PoolSectionBanner />}
viewerToolbarLeft={<EditorViewerToolbarLeft />}
viewerToolbarRight={
<EditorViewerToolbarRight
Expand Down
31 changes: 22 additions & 9 deletions apps/editor/lib/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@ import {
environmentPlugin,
environmentPresentation,
} from '@pascal-app/plugin-environment'
import { poolHostPanel, poolPlugin } from '@pascal-app/plugin-pool'
import { streetscapeHostPanel, streetscapePlugin } from '@pascal-app/plugin-streetscape'
import { treesHostPanel, treesPlugin } from '@pascal-app/plugin-trees'
import { registerViewerPresentation } from '@pascal-app/viewer'
import { webXRHostPanel, webXRPlugin } from '@webxr/plugin'
Expand Down Expand Up @@ -105,13 +103,28 @@ extendPluginDiscovery(async () => [bonesPlugin])
registerEditorHostPanel({ ...bonesHostPanel, defaultInstalled: false })
extendPluginDiscovery(async () => [mintPlugin])
registerEditorHostPanel(mintHostPanel)
extendPluginDiscovery(async () => [poolPlugin])
registerEditorHostPanel(poolHostPanel)
extendPluginDiscovery(async () => [streetscapePlugin])
// The upstream manifest still names 'Pascal' as creator; credit the author.
registerEditorHostPanel({
...streetscapeHostPanel,
creator: { name: 'Sudhir Yadav', url: 'https://github.com/sudhir9297' },
extendPluginDiscovery(async () => {
const { poolHostPanel, poolPlugin } = await import('@pascal-app/plugin-pool')
registerEditorHostPanel(poolHostPanel)
return [poolPlugin]
})
extendPluginDiscovery(async () => {
const { landscapeHostPanel, landscapePlugin } = await import('@pascal-app/plugin-landscape')
registerEditorHostPanel(landscapeHostPanel)
return [landscapePlugin]
})
extendPluginDiscovery(async () => {
const { bathSpaceHostPanel, bathSpacePlugin } = await import('@pascal-app/plugin-bath-space')
registerEditorHostPanel(bathSpaceHostPanel)
return [bathSpacePlugin]
})
extendPluginDiscovery(async () => {
const { streetscapeHostPanel, streetscapePlugin } = await import('@pascal-app/plugin-streetscape')
registerEditorHostPanel({
...streetscapeHostPanel,
creator: { name: 'Sudhir Yadav', url: 'https://github.com/sudhir9297' },
})
return [streetscapePlugin]
})
extendPluginDiscovery(async () => [webXRPlugin])
registerEditorHostPanel(webXRHostPanel)
Expand Down
20 changes: 20 additions & 0 deletions apps/editor/lib/graph-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,26 @@ test('accepts a graph containing a plugin node kind', () => {
expect(apiGraphSchema.safeParse(graph).success).toBe(true)
})

test('accepts namespaced attachment slots while validating plugin asset URLs', () => {
const node = pluginTree({ slotId: `wall-point:${TREE_ID}` })
expect(apiGraphSchema.safeParse(buildGraph({ [TREE_ID]: node })).success).toBe(true)

for (const url of [
'javascript:alert(1)',
'file:///etc/passwd',
'javascript:alert',
'file:secret',
'data:text',
]) {
expect(
apiGraphSchema.safeParse(buildGraph({ [TREE_ID]: { ...node, thumbnail: url } })).success,
).toBe(false)
expect(
apiGraphSchema.safeParse(buildGraph({ [TREE_ID]: { ...node, slotId: url } })).success,
).toBe(false)
}
})

test('accepts a builtin container whose children include a plugin node id', () => {
const graph = buildGraph({ [LEVEL_ID]: level([TREE_ID]), [TREE_ID]: pluginTree() }, [LEVEL_ID])

Expand Down
12 changes: 10 additions & 2 deletions apps/editor/lib/graph-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,7 @@ type ScanResult = { path: (string | number)[]; reason: 'url' | 'too-complex' }
/**
* First URL-shaped string `AssetUrl` rejects, or a breach of the walk bounds.
*
* The node's own `type` is exempt: a namespaced kind (`trees:tree`) is
* scheme-shaped by construction, and the envelope has already validated it.
* Namespaced kinds and attachment slot identifiers are not asset URLs.
*/
function findRejectedUrl(root: unknown): ScanResult | null {
let budget = MAX_SCAN_VALUES
Expand All @@ -85,6 +84,15 @@ function findRejectedUrl(root: unknown): ScanResult | null {
if (value && typeof value === 'object') {
for (const [key, child] of Object.entries(value)) {
if (depth === 0 && key === 'type') continue
// Wall attachment slots contain a node ID, not a URL scheme.
if (
depth === 0 &&
key === 'slotId' &&
typeof child === 'string' &&
/^wall-point:[\w.-]+$/.test(child)
) {
continue
}
Comment thread
sudhir9297 marked this conversation as resolved.
const hit = walk(child, [...path, key], depth + 1)
if (hit) return hit
}
Expand Down
16 changes: 15 additions & 1 deletion apps/editor/lib/scene-signature.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { expect, test } from 'bun:test'
import { type PersistedSceneGraph, sceneGraphSignature } from './scene-signature'
import {
isPendingSceneGraphEcho,
type PersistedSceneGraph,
sceneGraphSignature,
} from './scene-signature'

const NODE_ID = 'level_a1b2c3d4e5f6g7h8'
const MATERIAL_ID = 'mat_a1b2c3d4e5f6g7h8'
Expand Down Expand Up @@ -39,3 +43,13 @@ test('changing any signed field changes the signature', () => {
base,
)
})

test('an in-flight save echo cannot replace tools when event metadata is omitted', () => {
const saved = graph({ installedPlugins: ['pascal:bath-space'], collections: { col_1: {} } })
const pending = new Set([sceneGraphSignature(saved)])
expect(isPendingSceneGraphEcho(graph(), pending)).toBe(true)
expect(isPendingSceneGraphEcho(graph({ installedPlugins: [] }), pending)).toBe(false)
expect(isPendingSceneGraphEcho(graph({ rootNodeIds: [] }), pending)).toBe(false)
pending.clear()
expect(isPendingSceneGraphEcho(saved, pending)).toBe(false)
})
22 changes: 22 additions & 0 deletions apps/editor/lib/scene-signature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,25 @@ export function sceneGraphSignature(graph: PersistedSceneGraph): string {
installedPlugins: graph.installedPlugins ?? [],
})
}

/** Match a pending save even when an event omits optional graph metadata. */
export function isPendingSceneGraphEcho(
graph: PersistedSceneGraph,
pending: ReadonlySet<string>,
): boolean {
if (pending.has(sceneGraphSignature(graph))) return true
for (const signature of pending) {
const saved = JSON.parse(signature) as PersistedSceneGraph
if (
sceneGraphSignature({
...saved,
...graph,
collections: graph.collections ?? saved.collections,
materials: graph.materials ?? saved.materials,
installedPlugins: graph.installedPlugins ?? saved.installedPlugins,
}) === signature
)
return true
}
return false
}
22 changes: 17 additions & 5 deletions apps/editor/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import type { NextConfig } from 'next'

const appDirectory = path.dirname(fileURLToPath(import.meta.url))
const portableBuild = process.env.PASCAL_PORTABLE_BUILD === '1'

const nextConfig: NextConfig = {
...(portableBuild
? { output: 'standalone' as const, outputFileTracingRoot: path.join(appDirectory, '../..') }
Expand Down Expand Up @@ -44,6 +43,8 @@ const nextConfig: NextConfig = {
'@pascal-app/editor',
'@pascal-app/mcp',
'@pascal-app/plugin-pool',
'@pascal-app/plugin-bath-space',
'@pascal-app/plugin-landscape',
'@pascal-app/plugin-streetscape',
'@pascal-app/plugin-trees',
'@mint/pascal-plugin',
Expand All @@ -53,11 +54,22 @@ const nextConfig: NextConfig = {
'@dgreenheck/ez-tree',
],
turbopack: {
root: path.resolve(appDirectory, '../..'),
resolveAlias: {
react: './node_modules/react',
three: './node_modules/three',
'@react-three/fiber': './node_modules/@react-three/fiber',
'@react-three/drei': './node_modules/@react-three/drei',
'@pascal-app/core': './../../node_modules/@pascal-app/core',
'@pascal-app/editor': './../../node_modules/@pascal-app/editor',
'@pascal-app/viewer': './../../node_modules/@pascal-app/viewer',
'@pascal-app/nodes': './../../node_modules/@pascal-app/nodes',
react: './../../node_modules/react',
'react-dom': './../../node_modules/react-dom',
three: './../../node_modules/three',
'@react-three/fiber': './../../node_modules/@react-three/fiber',
'@react-three/drei': './../../node_modules/@react-three/drei',
'@react-three/xr': './../../node_modules/@react-three/xr',
zustand: './../../node_modules/zustand',
zod: './../../node_modules/zod',
iwer: './../../node_modules/iwer',
'@iwer/devui': './../../node_modules/@iwer/devui',
},
},
experimental: {
Expand Down
8 changes: 5 additions & 3 deletions apps/editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,18 @@
"@pascal-app/nodes": "*",
"@pascal-app/plugin-bones": "github:pascalorg/plugin-bones#5679260261ee1c733656ff6dfb99e30bb24b58a7",
"@pascal-app/plugin-environment": "github:AxiomeCG/environment#1aa0bc3cbed273dbf751e9af79e446f6de105c90",
"@pascal-app/plugin-pool": "github:sudhir9297/pool-pascal-plugin#483a080558293cd344170e66318230e2ac9b6ae8",
"@pascal-app/plugin-streetscape": "github:wass08/streetscape-pascal-plugin#1d9fe5e442fb7c0048066f3450ec54ccd8dd1f0f",
"@pascal-app/plugin-bath-space": "github:sudhir9297/pascal-cluster#357338ddb7e042498fca7b6e6ee2054edbeb70b7",
"@pascal-app/plugin-landscape": "github:sudhir9297/pascal-cluster#d60ee247334dad037e02a432a50bed9264193402",
"@pascal-app/plugin-pool": "github:sudhir9297/pool-pascal-plugin#1da7fb0d29691f3e2e488e24b436261266c692ce",
"@pascal-app/plugin-streetscape": "github:pascalorg/plugin-streetscape#bb563f3739a0dbaf7a3d0cc40f7ef9486951b0a1",
"@pascal-app/plugin-trees": "github:pascalorg/plugin-trees#56d978cd9b409b716207b3f3d269455d3cd6f067",
"@pascal-app/viewer": "*",
"@radix-ui/react-tooltip": "^1.4.1",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.8.1",
"@react-three/xr": "^6.6.30",
"@tailwindcss/postcss": "^4.2.1",
"@webxr/plugin": "github:sudhir9297/webxr-pascal-plugin#31063230d0cd5b244b04eca12c6e31b5b757cb5e",
"@webxr/plugin": "github:sudhir9297/webxr-pascal-plugin#9cd90457fc823d5d633d38e9575856c854aeb4d4",
"clsx": "^2.1.1",
"geist": "^1.7.2",
"lucide-react": "^1.57.0",
Expand Down
Loading
Loading