From cef6796cf745ad1e83c9c49e9832ec51f551d32d Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 11 Nov 2026 12:26:25 -0500 Subject: [PATCH] Add a render-only ./render entry for read-only viewers Split boots:job into definition.render.ts (schema, defaults, capabilities, cone renderer, presentation, mcp) and definition.edit.ts (snapProfile, inspector parameters, and the game engine on the system slot, which reaches @pascal-app/editor). definition.ts composes both, so bootsPlugin is unchanged. @pascal-app/plugin-boots/render exports bootsRenderPlugin. check:render bundles it and fails on any @pascal-app/editor or next import; CI runs it. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 1 + README.md | 12 +++++ package.json | 5 ++ scripts/check-render-entry.ts | 74 +++++++++++++++++++++++++++++ src/definition.edit.ts | 36 +++++++++++++++ src/definition.render.ts | 74 +++++++++++++++++++++++++++++ src/definition.ts | 87 ++++------------------------------- src/render.test.ts | 44 ++++++++++++++++++ src/render.ts | 17 +++++++ 9 files changed, 272 insertions(+), 78 deletions(-) create mode 100644 scripts/check-render-entry.ts create mode 100644 src/definition.edit.ts create mode 100644 src/definition.render.ts create mode 100644 src/render.test.ts create mode 100644 src/render.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f03edf2..a9bb91c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,3 +20,4 @@ jobs: - run: bun install --frozen-lockfile - run: bun run check-types - run: bun test + - run: bun run check:render diff --git a/README.md b/README.md index 19d5d93..5130d6e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/package.json b/package.json index 4e23172..2c9c5b0 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,10 @@ ".": { "types": "./src/index.ts", "default": "./src/index.ts" + }, + "./render": { + "types": "./src/render.ts", + "default": "./src/render.ts" } }, "files": [ @@ -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": { diff --git a/scripts/check-render-entry.ts b/scripts/check-render-entry.ts new file mode 100644 index 0000000..de63c53 --- /dev/null +++ b/scripts/check-render-entry.ts @@ -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') diff --git a/src/definition.edit.ts b/src/definition.edit.ts new file mode 100644 index 0000000..836844a --- /dev/null +++ b/src/definition.edit.ts @@ -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). +export const jobEditContribution: Pick, '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 }, +} diff --git a/src/definition.render.ts b/src/definition.render.ts new file mode 100644 index 0000000..602fce5 --- /dev/null +++ b/src/definition.render.ts @@ -0,0 +1,74 @@ +import type { NodeDefinition } from '@pascal-app/core' +import { JobNode } from './schema' + +type JobDefinition = NodeDefinition & Record + +/** 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.', + }, +} diff --git a/src/definition.ts b/src/definition.ts index fadbd4a..828c32b 100644 --- a/src/definition.ts +++ b/src/definition.ts @@ -1,86 +1,17 @@ -import type { NodeDefinition } from '@pascal-app/core' -import { JobNode } from './schema' - -type JobDefinition = NodeDefinition & Record - -/** 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, } diff --git a/src/render.test.ts b/src/render.test.ts new file mode 100644 index 0000000..2d025e5 --- /dev/null +++ b/src/render.test.ts @@ -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 + expect(job.snapProfile).toBe('item') + expect(job.parametrics).toBeDefined() + expect(job.system).toBeDefined() + }) +}) diff --git a/src/render.ts b/src/render.ts new file mode 100644 index 0000000..548e759 --- /dev/null +++ b/src/render.ts @@ -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'