Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ jobs:
- run: bun install --frozen-lockfile
- run: bun run check-types
- run: bun test
- run: bun run check:render
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,18 @@ bun run check-types

Host wiring follows the [plugin guide](https://editor.pascal.app/docs/developers/plugins): pin the package, add it to `transpilePackages`, `extendPluginDiscovery(async () => [bootsPlugin])`, `registerEditorHostPanel(bootsHostPanel)`. Ships `defaultInstalled: false` — users enable it per scene from the Plugins panel.

## Read-only viewers

A viewer that renders saved projects without `@pascal-app/editor` loads the render-only entry. It registers the same plugin id and `boots:job` kind (schema, defaults and the cone renderer) without the inspector parameters, the first-person game or the host panel:

```ts
import { bootsRenderPlugin } from '@pascal-app/plugin-boots/render'

extendPluginDiscovery(async () => [bootsRenderPlugin])
```

`bun run check:render` bundles `./render` and fails if anything in it reaches `@pascal-app/editor` or `next`.

## License

MIT
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./render": {
"types": "./src/render.ts",
"default": "./src/render.ts"
}
},
"files": [
Expand All @@ -26,6 +30,7 @@
"scripts": {
"check-types": "tsc --noEmit",
"test": "bun test",
"check:render": "bun scripts/check-render-entry.ts",
"avatar:build": "bash scripts/build-avatar.sh"
},
"peerDependencies": {
Expand Down
74 changes: 74 additions & 0 deletions scripts/check-render-entry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* Render-entry boundary gate: bundles `@pascal-app/plugin-boots/render` (every
* static and lazy import, dependencies included) and fails if anything resolves
* to `@pascal-app/editor` or `next`. As a self-test it bundles the root entry
* too, which must fail — the game engine on the system slot reaches the editor.
*
* Run: bun run check:render
*/
import { realpathSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import type { BunPlugin } from 'bun'

const root = resolve(import.meta.dir, '..')
const forbiddenSpecifier = /^(?:@pascal-app\/editor|next)(?:\/|$)/
const forbiddenPath = /(?:^|\/)node_modules\/(?:@pascal-app\/editor|next)(?:\/|$)/

function boundary(violations: string[]): BunPlugin {
return {
name: 'render-boundary',
setup(build) {
build.onResolve({ filter: /.*/ }, (args) => {
// three's examples barrel carries CDN URL imports; they are not packages.
if (/^https?:\/\//.test(args.path)) return { path: args.path, external: true }
if (forbiddenSpecifier.test(args.path)) {
violations.push(`${args.path} (imported by ${args.importer})`)
return { path: args.path, external: true }
}
let resolved: string | undefined
try {
resolved = realpathSync(Bun.resolveSync(args.path, dirname(args.importer || root)))
} catch {
return undefined // let the bundler report a genuinely unresolvable import
}
if (forbiddenPath.test(resolved.replaceAll('\\', '/'))) {
violations.push(`${resolved} (imported by ${args.importer})`)
return { path: args.path, external: true }
}
return undefined
})
},
}
}

async function check(entry: string) {
const violations: string[] = []
const result = await Bun.build({
entrypoints: [resolve(root, entry)],
target: 'browser',
splitting: true,
throw: false,
loader: { '.glb': 'file', '.webp': 'file', '.jpg': 'file', '.png': 'file' },
plugins: [boundary(violations)],
})
if (!result.success) {
for (const log of result.logs) console.error(log)
throw new Error(`Bundling ${entry} failed`)
}
return violations
}

const renderViolations = await check('src/render.ts')
if (renderViolations.length) {
console.error('src/render.ts reaches forbidden render dependencies:')
for (const v of renderViolations) console.error(` - ${v}`)
process.exit(1)
}

const rootViolations = await check('src/index.ts')
if (!rootViolations.length) {
console.error('Self-test failed: the root entry should reach @pascal-app/editor via the game system')
process.exit(1)
}

console.log('check:render ok — ./render reaches neither @pascal-app/editor nor next')
36 changes: 36 additions & 0 deletions src/definition.edit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { NodeDefinition } from '@pascal-app/core'
import type { JobNode } from './schema'

/**
* The editing half of the job-marker definition: the item snap profile, the
* inspector parameters, and the Boots game engine on the system slot.
*
* The system is the first-person game (Jump in, the share-link drop gate, the
* spectator layer and the pending Save/Discard preview). It is an editing
* instrument — it writes the scene on Save and reaches into
* `@pascal-app/editor` (view mode, item catalog) — so read-only viewers load
* `jobRenderDefinition` without it.
*/
// `snapProfile` is newer than the pinned core's NodeDefinition type, hence the
// intersection (the original single definition widened with Record<string, unknown>).
export const jobEditContribution: Pick<NodeDefinition<typeof JobNode>, 'parametrics' | 'system'> & {
snapProfile: 'item'
} = {
snapProfile: 'item',

parametrics: {
groups: [
{
label: 'Job',
fields: [
{ key: 'job', kind: 'enum', options: ['fix', 'paint', 'install', 'clean', 'inspect'] },
{ key: 'status', kind: 'enum', options: ['open', 'done'], display: 'segmented' },
],
},
],
},

// The game engine rides the system slot: mounted in-canvas whenever Boots
// is installed in the scene, inert until the panel enters game phase.
system: { module: () => import('./game/system'), priority: 8 },
}
74 changes: 74 additions & 0 deletions src/definition.render.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { NodeDefinition } from '@pascal-app/core'
import { JobNode } from './schema'

type JobDefinition = NodeDefinition<typeof JobNode> & Record<string, unknown>

/** Cone footprint — a real site cone: ~36 cm square base, ~55 cm tall. */
const JOB_SIZE: [number, number, number] = [0.36, 0.55, 0.36]

const jobFloorPlacement = {
footprint: (node: unknown) => ({
dimensions: JOB_SIZE,
rotation: (node as JobNode).rotation,
}),
// A marker, not furniture — it neither blocks placements nor gets blocked.
collides: false,
}

/**
* The render half of the job-marker definition: everything a read-only viewer
* needs to load, validate and draw a `boots:job` cone — schema, defaults,
* capabilities (selection, bounds and transforms, as the built-ins keep them),
* the cone renderer and presentation. It imports only `@pascal-app/core`,
* `three`, `@react-three/*` and react (through the renderer), never the editor.
*
* The editing half lives in `definition.edit.ts`; `definition.ts` composes the
* two for editor hosts.
*/
export const jobRenderDefinition: JobDefinition = {
kind: 'boots:job',
schemaVersion: 1,
schema: JobNode,
category: 'furnish',

defaults: () => ({
object: 'node',
parentId: null,
visible: true,
metadata: {},
position: [0, 0, 0],
rotation: [0, 0, 0],
job: 'fix',
status: 'open',
}),

capabilities: {
movable: { axes: ['x', 'z'], gridSnap: true },
rotatable: {
axes: ['y'],
snapAngles: Array.from({ length: 8 }, (_, i) => (i * Math.PI) / 4),
},
selectable: { hitVolume: 'bbox' },
duplicable: true,
deletable: true,
groupable: true,
snappable: {},
dragBounds: () => ({ size: JOB_SIZE }),
floorPlaced: jobFloorPlacement,
},

renderer: { kind: 'parametric', module: () => import('./renderer') },

presentation: {
label: 'Job marker',
description: 'A punch-list cone standing where work is needed.',
icon: { kind: 'iconify', name: 'lucide:traffic-cone' },
paletteSection: 'furnish',
hidden: true,
},

mcp: {
description:
'A Boots punch-list job marker — a site cone placed where work is needed. `job` picks the trade (fix/paint/install/clean/inspect); `status` flips open → done when the work is finished.',
},
}
87 changes: 9 additions & 78 deletions src/definition.ts
Original file line number Diff line number Diff line change
@@ -1,86 +1,17 @@
import type { NodeDefinition } from '@pascal-app/core'
import { JobNode } from './schema'

type JobDefinition = NodeDefinition<typeof JobNode> & Record<string, unknown>

/** Cone footprint — a real site cone: ~36 cm square base, ~55 cm tall. */
const JOB_SIZE: [number, number, number] = [0.36, 0.55, 0.36]

const jobFloorPlacement = {
footprint: (node: unknown) => ({
dimensions: JOB_SIZE,
rotation: (node as JobNode).rotation,
}),
// A marker, not furniture — it neither blocks placements nor gets blocked.
collides: false,
}
import { jobEditContribution } from './definition.edit'
import { jobRenderDefinition } from './definition.render'

/**
* The job-marker node definition. A punch-list cone: place it where work is
* needed, walk up to it in first person, do the work, flip it to done.
* `parametrics` gives the inspector for free; the Boots panel lists every
* marker in the scene as the working punch list.
*
* Composed from the render half (`definition.render.ts`, what read-only
* viewers load through `@pascal-app/plugin-boots/render`) and the editing half
* (`definition.edit.ts`).
*/
export const jobDefinition: JobDefinition = {
kind: 'boots:job',
schemaVersion: 1,
schema: JobNode,
category: 'furnish',
snapProfile: 'item',

defaults: () => ({
object: 'node',
parentId: null,
visible: true,
metadata: {},
position: [0, 0, 0],
rotation: [0, 0, 0],
job: 'fix',
status: 'open',
}),

capabilities: {
movable: { axes: ['x', 'z'], gridSnap: true },
rotatable: {
axes: ['y'],
snapAngles: Array.from({ length: 8 }, (_, i) => (i * Math.PI) / 4),
},
selectable: { hitVolume: 'bbox' },
duplicable: true,
deletable: true,
groupable: true,
snappable: {},
dragBounds: () => ({ size: JOB_SIZE }),
floorPlaced: jobFloorPlacement,
},

parametrics: {
groups: [
{
label: 'Job',
fields: [
{ key: 'job', kind: 'enum', options: ['fix', 'paint', 'install', 'clean', 'inspect'] },
{ key: 'status', kind: 'enum', options: ['open', 'done'], display: 'segmented' },
],
},
],
},

renderer: { kind: 'parametric', module: () => import('./renderer') },
// The game engine rides the system slot: mounted in-canvas whenever Boots
// is installed in the scene, inert until the panel enters game phase.
system: { module: () => import('./game/system'), priority: 8 },

presentation: {
label: 'Job marker',
description: 'A punch-list cone standing where work is needed.',
icon: { kind: 'iconify', name: 'lucide:traffic-cone' },
paletteSection: 'furnish',
hidden: true,
},

mcp: {
description:
'A Boots punch-list job marker — a site cone placed where work is needed. `job` picks the trade (fix/paint/install/clean/inspect); `status` flips open → done when the work is finished.',
},
export const jobDefinition = {
...jobRenderDefinition,
...jobEditContribution,
}
44 changes: 44 additions & 0 deletions src/render.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, test } from 'bun:test'
import { bootsPlugin } from './index'
import { bootsRenderPlugin } from './render'

/** Fields that only an editing host reads; a render definition must not carry them. */
const EDIT_ONLY_FIELDS = [
'snapProfile',
'parametrics',
'system',
'preview',
'tool',
'toolHints',
'floorplanAffordances',
'floorplanMoveTarget',
] as const

describe('render entry', () => {
test('same manifest identity and kinds as the root plugin', () => {
expect(bootsRenderPlugin.id).toBe(bootsPlugin.id)
expect(bootsRenderPlugin.apiVersion).toBe(bootsPlugin.apiVersion)
const rootNodes = bootsPlugin.nodes ?? []
expect(bootsRenderPlugin.nodes.map((n) => n.kind)).toEqual(rootNodes.map((n) => n.kind))
for (const [i, renderNode] of bootsRenderPlugin.nodes.entries()) {
const rootNode = rootNodes[i]!
expect(renderNode.schema).toBe(rootNode.schema)
expect(renderNode.schemaVersion).toBe(rootNode.schemaVersion)
expect(renderNode.defaults()).toEqual(rootNode.defaults())
expect(renderNode.renderer).toBe(rootNode.renderer)
}
})

test('render definitions carry no edit-only fields', () => {
for (const node of bootsRenderPlugin.nodes) {
for (const field of EDIT_ONLY_FIELDS) expect(node).not.toHaveProperty(field)
}
})

test('the root definition keeps its editing contributions', () => {
const job = bootsPlugin.nodes?.[0] as unknown as Record<string, unknown>
expect(job.snapProfile).toBe('item')
expect(job.parametrics).toBeDefined()
expect(job.system).toBeDefined()
})
})
17 changes: 17 additions & 0 deletions src/render.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { AnyNodeDefinition, Plugin } from '@pascal-app/core'
import { jobRenderDefinition } from './definition.render'

/**
* Render-only Boots manifest for read-only viewers: the same plugin id and
* kinds as `bootsPlugin`, without parameters, the first-person game or the
* host panel. Nothing reachable from this entry imports `@pascal-app/editor`
* (`bun run check:render` holds that).
*/
export const bootsRenderPlugin = {
id: 'pascal:boots',
apiVersion: 1,
nodes: [jobRenderDefinition as unknown as AnyNodeDefinition],
} satisfies Plugin

export { jobRenderDefinition } from './definition.render'
export { JobKind, JobNode, JobStatus } from './schema'
Loading