diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e5f18e..4260722 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,3 +16,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 c7089a1..b53fa47 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,24 @@ part of the v1 core plugin manifest. The panel declares `pluginId` and `defaultInstalled: true`, so Nature is listed in the Plugins sidebar and is enabled for new and legacy scenes unless a project explicitly uninstalls it. +## Read-only viewers + +A viewer that only displays scenes can load the render-only entry instead. It +registers the same plugin id, kinds, schemas, defaults and bake policy, with the +renderers, systems, baked-scene renderers and 2D plan symbols, but no placement +tools, inspector, gizmos or Nature panel, and nothing from `@pascal-app/editor`: + +```ts +import { treesRenderPlugin } from '@pascal-app/plugin-trees/render' +// viewer host: +setPluginDiscovery(async () => [treesRenderPlugin]) +``` + +Each kind's definition is split into a render half (`*definition.render.ts`) +and an edit half (`*definition.edit.ts`); `treesPlugin` composes both, so +editing hosts are unchanged. `bun run check:render` bundles `./render` and fails +if anything in it reaches `@pascal-app/editor` or `next`. + ## Notes / known gaps - `package.json` points `main`/`exports` at raw TypeScript (`./src/index.ts`). diff --git a/package.json b/package.json index e073b0a..47f76f4 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": [ @@ -25,7 +29,8 @@ ], "scripts": { "check-types": "tsc --noEmit", - "test": "bun test" + "test": "bun test", + "check:render": "bun scripts/check-render-entry.ts" }, "dependencies": { "@dgreenheck/ez-tree": "^1.1.0" diff --git a/scripts/check-render-entry.ts b/scripts/check-render-entry.ts new file mode 100644 index 0000000..917b21a --- /dev/null +++ b/scripts/check-render-entry.ts @@ -0,0 +1,61 @@ +/** + * Render-entry boundary gate: bundles `./render` (every static and lazy import, + * peer dependencies included) and fails if anything in the graph resolves to + * `@pascal-app/editor` or `next`. A negative probe bundles the root entry and + * expects the gate to catch its editor imports, so a silently blind check fails. + */ +import { fileURLToPath } from 'node:url' +import type { BunPlugin } from 'bun' + +const FORBIDDEN = /^(@pascal-app\/editor|next)(\/|$)/ +const root = fileURLToPath(new URL('..', import.meta.url)) +const pkg = await Bun.file(`${root}package.json`).json() + +type Hit = { specifier: string; importer: string } + +async function forbiddenImports(entry: string): Promise { + const hits: Hit[] = [] + const gate: BunPlugin = { + name: 'render-entry-gate', + setup(build) { + build.onResolve({ filter: FORBIDDEN }, (args) => { + hits.push({ specifier: args.path, importer: args.importer.replace(root, '') }) + return { path: args.path, external: true } + }) + }, + } + const result = await Bun.build({ + entrypoints: [`${root}${entry.replace(/^\.\//, '')}`], + target: 'browser', + format: 'esm', + splitting: true, + loader: { '.webp': 'file' }, + plugins: [gate], + throw: false, + }) + if (!result.success) { + for (const log of result.logs) console.error(log) + throw new Error(`bundling ${entry} failed`) + } + return hits +} + +const renderEntry = pkg.exports['./render'].default as string +const rootEntry = pkg.exports['.'].default as string + +const renderHits = await forbiddenImports(renderEntry) +if (renderHits.length > 0) { + console.error(`./render (${renderEntry}) reaches editor-only modules:`) + for (const hit of renderHits) console.error(` ${hit.importer} -> ${hit.specifier}`) + process.exit(1) +} + +const probeHits = await forbiddenImports(rootEntry) +if (!probeHits.some((hit) => hit.specifier.startsWith('@pascal-app/editor'))) { + console.error(`negative probe: the root entry (${rootEntry}) should reach @pascal-app/editor`) + process.exit(1) +} + +console.log( + `./render is editor-free (negative probe caught ${probeHits.length} editor import(s) from the root entry).`, +) diff --git a/src/definition.edit.ts b/src/definition.edit.ts new file mode 100644 index 0000000..8ceca89 --- /dev/null +++ b/src/definition.edit.ts @@ -0,0 +1,53 @@ +import type { HandleDescriptor } from '@pascal-app/core' +import type { PlantEditContribution } from './edit-contribution' +import { treeTrunkRadius } from './floorplan' +import { treeParametrics } from './parametrics' +import type { TreeNode } from './schema' + +const ROTATE_RING_OFFSET = 0.35 +/** Ring hugs the ground like the item gizmo — high enough to clear the grass, + * low enough to read as a floor affordance. */ +const ROTATE_RING_Y = 0.25 + +/** Whole-tree Y-rotation gizmo (same rig as shelf/item): a ring around the + * trunk near the ground — not the canopy, which would put the handle meters + * from the trunk on a large oak. */ +function treeRotateHandle(): HandleDescriptor { + const ringRadius = (n: TreeNode) => treeTrunkRadius(n) + ROTATE_RING_OFFSET + const ringY = () => ROTATE_RING_Y + return { + kind: 'arc-resize', + axis: 'angular', + shape: 'rotate', + apply: (initial, delta) => { + const r = initial.rotation ?? [0, 0, 0] + // Negate to match three.js Y-rotation handedness (same as shelf). + return { rotation: [r[0], (r[1] ?? 0) - delta, r[2]] as [number, number, number] } + }, + placement: { + position: (n) => { + const r = ringRadius(n) + return [r * Math.SQRT1_2, ringY(), r * Math.SQRT1_2] + }, + rotationY: () => -Math.PI / 4, + }, + decoration: { + kind: 'ring', + radius: ringRadius, + y: ringY, + }, + } +} + +/** The tree's edit half: inspector, gizmo and placement (`tool`/`preview`). */ +export const treeEditContribution: PlantEditContribution = { + snapProfile: 'item', + parametrics: treeParametrics, + handles: [treeRotateHandle()], + preview: () => import('./preview'), + tool: () => import('./tool'), + toolHints: [ + { key: 'Left click', label: 'Plant tree' }, + { key: 'Esc', label: 'Stop' }, + ], +} diff --git a/src/definition.render.ts b/src/definition.render.ts new file mode 100644 index 0000000..ac7220f --- /dev/null +++ b/src/definition.render.ts @@ -0,0 +1,101 @@ +import type { NodeDefinition } from '@pascal-app/core' +import { buildTreeFloorplan, treeTrunkRadius } from './floorplan' +import { TreeNode } from './schema' + +type TreeDefinition = NodeDefinition & Record + +const treeFloorPlacement = { + footprint: (node: unknown) => { + const tree = node as TreeNode + const radius = treeTrunkRadius(tree) + return { + dimensions: [radius * 2, tree.height, radius * 2] as [number, number, number], + rotation: tree.rotation, + } + }, + collides: false, +} + +/** + * The tree's render half: everything a read-only viewer needs to load and draw + * `trees:tree` without `@pascal-app/editor`. Rendering uses the instanced path + * rather than the per-node `def.geometry`: a collective `def.system` batches + * every tree into `InstancedMesh`es (forest-scale draw calls), while a + * featherweight `def.renderer` mounts an invisible per-node proxy so the host's + * selection / outline / zone machinery works unchanged. Editing behavior lives + * in `definition.edit.ts`; `definition.ts` composes both. + */ +export const treeRenderDefinition: TreeDefinition = { + kind: 'trees:tree', + // Static in the bake for portability; our viewer removes the baked meshes and + // re-renders live (wind, LODs) via this def's own path. See plans → Part D. + bake: 'replace', + schemaVersion: 1, + schema: TreeNode, + category: 'furnish', + + defaults: () => ({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + position: [0, 0, 0], + rotation: [0, 0, 0], + preset: 'oak', + size: 'medium', + treeType: 'deciduous', + height: 7, + seed: 1, + foliageDensity: 1, + trunkThickness: 1, + leafless: false, + leafColor: '#ffffff', + branchColor: '#ffffff', + }), + + 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: {}, + // The auto-measured drag box would wrap the whole canopy (the proxy shows + // the real geometry while selected) — declare trunk-sized bounds instead. + dragBounds: (node) => { + const tree = node as unknown as TreeNode + const radius = treeTrunkRadius(tree) + return { size: [radius * 2, tree.height ?? 7, radius * 2] } + }, + // Defined outside the contextually typed object so the optional `collides` + // hint remains compatible with Pascal hosts released before that field. + floorPlaced: treeFloorPlacement, + }, + + // 2D plan symbol: dashed canopy ring + trunk dot (see floorplan.ts). + floorplan: buildTreeFloorplan, + + // Instanced rendering: an invisible per-node proxy for selection/outline... + renderer: { kind: 'parametric', module: () => import('./proxy-renderer') }, + // ...and a collective system that batches every tree into InstancedMeshes. + system: { module: () => import('./system'), priority: 3 }, + // Baked `/viewer` re-render for `bake: 'replace'` — collective, instanced. + bakeReplaceRenderer: { module: () => import('./static-renderer') }, + + presentation: { + label: 'Tree', + description: 'A procedural ez-tree. Oak, pine, aspen, ash, bush, or trellis.', + icon: { kind: 'iconify', name: 'lucide:trees' }, + paletteSection: 'furnish', + hidden: true, + }, + + mcp: { + description: + 'A procedural ez-tree (example plugin node). Species presets (oak/pine/aspen/ash/bush/trellis) × size, deciduous/evergreen type, adjustable height, foliage/trunk, leaf & branch tint, and a seed for variation.', + }, +} diff --git a/src/definition.ts b/src/definition.ts index 1fb9c6d..7147cc8 100644 --- a/src/definition.ts +++ b/src/definition.ts @@ -1,146 +1,8 @@ -import type { HandleDescriptor, NodeDefinition } from '@pascal-app/core' -import { buildTreeFloorplan, treeTrunkRadius } from './floorplan' -import { treeParametrics } from './parametrics' -import { TreeNode } from './schema' +import { treeEditContribution } from './definition.edit' +import { treeRenderDefinition } from './definition.render' -type TreeDefinition = NodeDefinition & Record - -const ROTATE_RING_OFFSET = 0.35 -/** Ring hugs the ground like the item gizmo — high enough to clear the grass, - * low enough to read as a floor affordance. */ -const ROTATE_RING_Y = 0.25 - -/** Whole-tree Y-rotation gizmo (same rig as shelf/item): a ring around the - * trunk near the ground — not the canopy, which would put the handle meters - * from the trunk on a large oak. */ -function treeRotateHandle(): HandleDescriptor { - const ringRadius = (n: TreeNode) => treeTrunkRadius(n) + ROTATE_RING_OFFSET - const ringY = () => ROTATE_RING_Y - return { - kind: 'arc-resize', - axis: 'angular', - shape: 'rotate', - apply: (initial, delta) => { - const r = initial.rotation ?? [0, 0, 0] - // Negate to match three.js Y-rotation handedness (same as shelf). - return { rotation: [r[0], (r[1] ?? 0) - delta, r[2]] as [number, number, number] } - }, - placement: { - position: (n) => { - const r = ringRadius(n) - return [r * Math.SQRT1_2, ringY(), r * Math.SQRT1_2] - }, - rotationY: () => -Math.PI / 4, - }, - decoration: { - kind: 'ring', - radius: ringRadius, - y: ringY, - }, - } -} - -const treeFloorPlacement = { - footprint: (node: unknown) => { - const tree = node as TreeNode - const radius = treeTrunkRadius(tree) - return { - dimensions: [radius * 2, tree.height, radius * 2] as [number, number, number], - rotation: tree.rotation, - } - }, - collides: false, -} - -/** - * The tree node definition. Rendering uses the instanced path rather than the - * per-node `def.geometry`: a collective `def.system` batches every tree into - * `InstancedMesh`es (forest-scale draw calls), while a featherweight - * `def.renderer` mounts an invisible per-node proxy so the host's selection / - * outline / zone machinery works unchanged. `parametrics` gives the inspector - * for free; `tool`/`preview` drive placement. No host dispatch code per kind. - */ -export const treeDefinition: TreeDefinition = { - kind: 'trees:tree', - // Static in the bake for portability; our viewer removes the baked meshes and - // re-renders live (wind, LODs) via this def's own path. See plans → Part D. - bake: 'replace', - schemaVersion: 1, - schema: TreeNode, - category: 'furnish', - snapProfile: 'item', - - defaults: () => ({ - object: 'node', - parentId: null, - visible: true, - metadata: {}, - position: [0, 0, 0], - rotation: [0, 0, 0], - preset: 'oak', - size: 'medium', - treeType: 'deciduous', - height: 7, - seed: 1, - foliageDensity: 1, - trunkThickness: 1, - leafless: false, - leafColor: '#ffffff', - branchColor: '#ffffff', - }), - - 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: {}, - // The auto-measured drag box would wrap the whole canopy (the proxy shows - // the real geometry while selected) — declare trunk-sized bounds instead. - dragBounds: (node) => { - const tree = node as unknown as TreeNode - const radius = treeTrunkRadius(tree) - return { size: [radius * 2, tree.height ?? 7, radius * 2] } - }, - // Defined outside the contextually typed object so the optional `collides` - // hint remains compatible with Pascal hosts released before that field. - floorPlaced: treeFloorPlacement, - }, - - parametrics: treeParametrics, - // 2D plan symbol: dashed canopy ring + trunk dot (see floorplan.ts). - floorplan: buildTreeFloorplan, - handles: [treeRotateHandle()], - - // Instanced rendering: an invisible per-node proxy for selection/outline... - renderer: { kind: 'parametric', module: () => import('./proxy-renderer') }, - // ...and a collective system that batches every tree into InstancedMeshes. - system: { module: () => import('./system'), priority: 3 }, - // Baked `/viewer` re-render for `bake: 'replace'` — collective, instanced. - bakeReplaceRenderer: { module: () => import('./static-renderer') }, - - preview: () => import('./preview'), - tool: () => import('./tool'), - toolHints: [ - { key: 'Left click', label: 'Plant tree' }, - { key: 'Esc', label: 'Stop' }, - ], - - presentation: { - label: 'Tree', - description: 'A procedural ez-tree. Oak, pine, aspen, ash, bush, or trellis.', - icon: { kind: 'iconify', name: 'lucide:trees' }, - paletteSection: 'furnish', - hidden: true, - }, - - mcp: { - description: - 'A procedural ez-tree (example plugin node). Species presets (oak/pine/aspen/ash/bush/trellis) × size, deciduous/evergreen type, adjustable height, foliage/trunk, leaf & branch tint, and a seed for variation.', - }, +/** The full tree definition for editing hosts: render half + edit half. */ +export const treeDefinition = { + ...treeRenderDefinition, + ...treeEditContribution, } diff --git a/src/edit-contribution.ts b/src/edit-contribution.ts new file mode 100644 index 0000000..82e2117 --- /dev/null +++ b/src/edit-contribution.ts @@ -0,0 +1,11 @@ +import type { AnyNodeDefinition, NodeDefinition } from '@pascal-app/core' + +/** + * The editing half of a plant kind: placement, inspector, gizmos and snapping. + * Read-only viewers load the render halves without it. `snapProfile` is not in + * every supported `@pascal-app/core` release's `NodeDefinition` type, so it is + * declared here. + */ +export type PlantEditContribution = Partial< + Pick, 'parametrics' | 'handles' | 'preview' | 'tool' | 'toolHints'> +> & { snapProfile?: 'item' } diff --git a/src/flower-definition.edit.ts b/src/flower-definition.edit.ts new file mode 100644 index 0000000..97ef71b --- /dev/null +++ b/src/flower-definition.edit.ts @@ -0,0 +1,15 @@ +import type { PlantEditContribution } from './edit-contribution' +import { flowerParametrics } from './flower-parametrics' +import type { FlowerNode } from './flower-schema' + +/** The flower's edit half: inspector and placement (`tool`/`preview`). */ +export const flowerEditContribution: PlantEditContribution = { + snapProfile: 'item', + parametrics: flowerParametrics, + preview: () => import('./flower-preview'), + tool: () => import('./flower-tool'), + toolHints: [ + { key: 'Left click', label: 'Plant flower' }, + { key: 'Esc', label: 'Stop' }, + ], +} diff --git a/src/flower-definition.render.ts b/src/flower-definition.render.ts new file mode 100644 index 0000000..f62811b --- /dev/null +++ b/src/flower-definition.render.ts @@ -0,0 +1,77 @@ +import type { NodeDefinition } from '@pascal-app/core' +import { buildFlowerFloorplan } from './floorplan' +import { FlowerNode } from './flower-schema' + +type FlowerDefinition = NodeDefinition & Record + +const flowerFloorPlacement = { + footprint: (node: unknown) => { + const flower = node as FlowerNode + const radius = Math.max(0.1, flower.height * 0.25) + return { + dimensions: [radius * 2, flower.height, radius * 2] as [number, number, number], + rotation: flower.rotation, + } + }, + collides: false, +} + +/** + * The flower's render half, a sibling instanced kind to the tree. A + * `def.system` batches every flower into InstancedMeshes and a featherweight + * `def.renderer` proxy keeps selection working. Editing behavior lives in + * `flower-definition.edit.ts`; `flower-definition.ts` composes both. + */ +export const flowerRenderDefinition: FlowerDefinition = { + kind: 'trees:flower', + bake: 'replace', // static in bake, live-rebuilt in our viewer — see plans → Part D + schemaVersion: 1, + schema: FlowerNode, + category: 'furnish', + + defaults: () => ({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + position: [0, 0, 0], + rotation: [0, 0, 0], + preset: 'daisy', + height: 0.5, + seed: 1, + petalColor: '#fcfcf2', + }), + + 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: {}, + floorPlaced: flowerFloorPlacement, + }, + + floorplan: buildFlowerFloorplan, + + renderer: { kind: 'parametric', module: () => import('./flower-proxy-renderer') }, + system: { module: () => import('./flower-system'), priority: 3 }, + bakeReplaceRenderer: { module: () => import('./flower-static-renderer') }, + + presentation: { + label: 'Flower', + description: 'A procedural flower. Daisy, tulip, or lavender.', + icon: { kind: 'iconify', name: 'lucide:flower-2' }, + paletteSection: 'furnish', + hidden: true, + }, + + mcp: { + description: + 'A procedural flower (example plugin node) — daisy, tulip, or lavender, instanced like the trees.', + }, +} diff --git a/src/flower-definition.ts b/src/flower-definition.ts index b784964..e89551d 100644 --- a/src/flower-definition.ts +++ b/src/flower-definition.ts @@ -1,87 +1,8 @@ -import type { NodeDefinition } from '@pascal-app/core' -import { buildFlowerFloorplan } from './floorplan' -import { flowerParametrics } from './flower-parametrics' -import { FlowerNode } from './flower-schema' +import { flowerEditContribution } from './flower-definition.edit' +import { flowerRenderDefinition } from './flower-definition.render' -type FlowerDefinition = NodeDefinition & Record - -const flowerFloorPlacement = { - footprint: (node: unknown) => { - const flower = node as FlowerNode - const radius = Math.max(0.1, flower.height * 0.25) - return { - dimensions: [radius * 2, flower.height, radius * 2] as [number, number, number], - rotation: flower.rotation, - } - }, - collides: false, -} - -/** - * The flower node definition — a sibling instanced kind to the tree. Same - * composition: a `def.system` batches every flower into InstancedMeshes, a - * featherweight `def.renderer` proxy keeps selection working, `parametrics` - * gives the inspector, `tool`/`preview` drive placement. - */ -export const flowerDefinition: FlowerDefinition = { - kind: 'trees:flower', - bake: 'replace', // static in bake, live-rebuilt in our viewer — see plans → Part D - schemaVersion: 1, - schema: FlowerNode, - category: 'furnish', - snapProfile: 'item', - - defaults: () => ({ - object: 'node', - parentId: null, - visible: true, - metadata: {}, - position: [0, 0, 0], - rotation: [0, 0, 0], - preset: 'daisy', - height: 0.5, - seed: 1, - petalColor: '#fcfcf2', - }), - - 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: {}, - floorPlaced: flowerFloorPlacement, - }, - - parametrics: flowerParametrics, - floorplan: buildFlowerFloorplan, - - renderer: { kind: 'parametric', module: () => import('./flower-proxy-renderer') }, - system: { module: () => import('./flower-system'), priority: 3 }, - bakeReplaceRenderer: { module: () => import('./flower-static-renderer') }, - - preview: () => import('./flower-preview'), - tool: () => import('./flower-tool'), - toolHints: [ - { key: 'Left click', label: 'Plant flower' }, - { key: 'Esc', label: 'Stop' }, - ], - - presentation: { - label: 'Flower', - description: 'A procedural flower. Daisy, tulip, or lavender.', - icon: { kind: 'iconify', name: 'lucide:flower-2' }, - paletteSection: 'furnish', - hidden: true, - }, - - mcp: { - description: - 'A procedural flower (example plugin node) — daisy, tulip, or lavender, instanced like the trees.', - }, +/** The full flower definition for editing hosts: render half + edit half. */ +export const flowerDefinition = { + ...flowerRenderDefinition, + ...flowerEditContribution, } diff --git a/src/grass-definition.edit.ts b/src/grass-definition.edit.ts new file mode 100644 index 0000000..4836db2 --- /dev/null +++ b/src/grass-definition.edit.ts @@ -0,0 +1,15 @@ +import type { PlantEditContribution } from './edit-contribution' +import { grassParametrics } from './grass-parametrics' +import type { GrassNode } from './grass-schema' + +/** The grass's edit half: inspector and placement (`tool`/`preview`). */ +export const grassEditContribution: PlantEditContribution = { + snapProfile: 'item', + parametrics: grassParametrics, + preview: () => import('./grass-preview'), + tool: () => import('./grass-tool'), + toolHints: [ + { key: 'Left click', label: 'Plant grass' }, + { key: 'Esc', label: 'Stop' }, + ], +} diff --git a/src/grass-definition.render.ts b/src/grass-definition.render.ts new file mode 100644 index 0000000..9bf30af --- /dev/null +++ b/src/grass-definition.render.ts @@ -0,0 +1,77 @@ +import type { NodeDefinition } from '@pascal-app/core' +import { buildGrassFloorplan } from './floorplan' +import { GrassNode } from './grass-schema' + +type GrassDefinition = NodeDefinition & Record + +const grassFloorPlacement = { + footprint: (node: unknown) => { + const grass = node as GrassNode + const radius = Math.max(0.1, grass.height * 0.3) + return { + dimensions: [radius * 2, grass.height, radius * 2] as [number, number, number], + rotation: grass.rotation, + } + }, + collides: false, +} + +/** + * The grass's render half, a third instanced kind alongside trees and flowers. + * A `def.system` batches every tuft into InstancedMeshes and a featherweight + * `def.renderer` proxy keeps selection working. Editing behavior lives in + * `grass-definition.edit.ts`; `grass-definition.ts` composes both. + */ +export const grassRenderDefinition: GrassDefinition = { + kind: 'trees:grass', + bake: 'replace', // static in bake, live-rebuilt in our viewer — see plans → Part D + schemaVersion: 1, + schema: GrassNode, + category: 'furnish', + + defaults: () => ({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + position: [0, 0, 0], + rotation: [0, 0, 0], + preset: 'meadow', + height: 0.4, + seed: 1, + bladeColor: '#5a8f3c', + }), + + 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: {}, + floorPlaced: grassFloorPlacement, + }, + + floorplan: buildGrassFloorplan, + + renderer: { kind: 'parametric', module: () => import('./grass-proxy-renderer') }, + system: { module: () => import('./grass-system'), priority: 3 }, + bakeReplaceRenderer: { module: () => import('./grass-static-renderer') }, + + presentation: { + label: 'Grass', + description: 'A procedural grass tuft. Meadow, fescue, or reed.', + icon: { kind: 'iconify', name: 'lucide:wheat' }, + paletteSection: 'furnish', + hidden: true, + }, + + mcp: { + description: + 'A procedural grass tuft (example plugin node) — meadow, fescue, or reed, instanced like the trees.', + }, +} diff --git a/src/grass-definition.ts b/src/grass-definition.ts index 56f82d6..75a57c4 100644 --- a/src/grass-definition.ts +++ b/src/grass-definition.ts @@ -1,87 +1,8 @@ -import type { NodeDefinition } from '@pascal-app/core' -import { buildGrassFloorplan } from './floorplan' -import { grassParametrics } from './grass-parametrics' -import { GrassNode } from './grass-schema' +import { grassEditContribution } from './grass-definition.edit' +import { grassRenderDefinition } from './grass-definition.render' -type GrassDefinition = NodeDefinition & Record - -const grassFloorPlacement = { - footprint: (node: unknown) => { - const grass = node as GrassNode - const radius = Math.max(0.1, grass.height * 0.3) - return { - dimensions: [radius * 2, grass.height, radius * 2] as [number, number, number], - rotation: grass.rotation, - } - }, - collides: false, -} - -/** - * The grass node definition — a third instanced kind alongside trees & flowers. - * Same composition: a `def.system` batches every tuft into InstancedMeshes, a - * featherweight `def.renderer` proxy keeps selection working, `parametrics` - * gives the inspector, `tool`/`preview` drive placement. - */ -export const grassDefinition: GrassDefinition = { - kind: 'trees:grass', - bake: 'replace', // static in bake, live-rebuilt in our viewer — see plans → Part D - schemaVersion: 1, - schema: GrassNode, - category: 'furnish', - snapProfile: 'item', - - defaults: () => ({ - object: 'node', - parentId: null, - visible: true, - metadata: {}, - position: [0, 0, 0], - rotation: [0, 0, 0], - preset: 'meadow', - height: 0.4, - seed: 1, - bladeColor: '#5a8f3c', - }), - - 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: {}, - floorPlaced: grassFloorPlacement, - }, - - parametrics: grassParametrics, - floorplan: buildGrassFloorplan, - - renderer: { kind: 'parametric', module: () => import('./grass-proxy-renderer') }, - system: { module: () => import('./grass-system'), priority: 3 }, - bakeReplaceRenderer: { module: () => import('./grass-static-renderer') }, - - preview: () => import('./grass-preview'), - tool: () => import('./grass-tool'), - toolHints: [ - { key: 'Left click', label: 'Plant grass' }, - { key: 'Esc', label: 'Stop' }, - ], - - presentation: { - label: 'Grass', - description: 'A procedural grass tuft. Meadow, fescue, or reed.', - icon: { kind: 'iconify', name: 'lucide:wheat' }, - paletteSection: 'furnish', - hidden: true, - }, - - mcp: { - description: - 'A procedural grass tuft (example plugin node) — meadow, fescue, or reed, instanced like the trees.', - }, +/** The full grass definition for editing hosts: render half + edit half. */ +export const grassDefinition = { + ...grassRenderDefinition, + ...grassEditContribution, } diff --git a/src/index.ts b/src/index.ts index 4256afa..e44ef89 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import { NATURE_ICON } from './art' import { treeDefinition } from './definition' import { flowerDefinition } from './flower-definition' import { grassDefinition } from './grass-definition' +import { treesRenderPlugin } from './render' type PluginHostPanel = { id: string @@ -23,15 +24,15 @@ type PluginHostPanel = { } /** - * The trees plugin manifest — the entire public surface of this package. A host - * loads it through the same `loadPlugin` path the built-ins use: three node kinds - * (`trees:tree`, `trees:flower`, `trees:grass`) and one left-rail panel - * (`Trees`). Cast mirrors the built-in bundle: `AnyNodeDefinition` is the - * hand-maintained union today; the registry derives it post-migration. + * The trees plugin manifest for editing hosts. A host loads it through the same + * `loadPlugin` path the built-ins use: three node kinds (`trees:tree`, + * `trees:flower`, `trees:grass`), each its render half (`./render`) composed + * with its edit half. The Nature panel is exported separately below. Cast + * mirrors the built-in bundle: `AnyNodeDefinition` is the hand-maintained union + * today; the registry derives it post-migration. */ export const treesPlugin: Plugin = { - id: 'pascal:trees', - apiVersion: 1, + ...treesRenderPlugin, nodes: [ treeDefinition as unknown as AnyNodeDefinition, flowerDefinition as unknown as AnyNodeDefinition, diff --git a/src/render.test.ts b/src/render.test.ts new file mode 100644 index 0000000..79be1bb --- /dev/null +++ b/src/render.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from 'bun:test' +import { treesPlugin } from './index' +import { treesRenderPlugin } from './render' + +// Definition fields only an editing host reads (the editor's edit-contribution +// field list); none may appear on a render definition. +const EDIT_FIELDS = [ + 'extensions', + 'facingIndicator', + 'drafting', + 'connectedMove', + 'parametrics', + 'placementNotice', + 'quickActions', + 'quickActionNodeScope', + 'tool', + 'affordanceTools', + 'affordances', + 'toolHints', + 'affordanceHints', + 'toolOptions', + 'snapProfile', + 'snapDraftDirectional', + 'preview', + 'keyboardActions', + 'handles', + 'floorplanDependencies', + 'floorplanDependsOnSiblings', + 'floorplanAffectedIds', + 'floorplanSiblingOverrides', + 'floorplanAffordances', + 'floorplanMoveTarget', +] + +describe('render-only plugin', () => { + const renderNodes = treesRenderPlugin.nodes ?? [] + const fullNodes = treesPlugin.nodes ?? [] + + test('keeps the root plugin identity and registers the same kinds', () => { + expect(treesRenderPlugin.id).toBe(treesPlugin.id) + expect(treesRenderPlugin.apiVersion).toBe(treesPlugin.apiVersion) + expect(renderNodes.map((def) => def.kind)).toEqual(fullNodes.map((def) => def.kind)) + }) + + test('each kind loads the same documents as in the editor', () => { + for (const [i, render] of renderNodes.entries()) { + const full = fullNodes[i] as unknown as Record + const def = render as unknown as Record + for (const key of ['schema', 'schemaVersion', 'migrations', 'bake', 'category'] as const) { + expect(def[key]).toBe(full[key]) + } + expect(render.defaults?.()).toEqual(fullNodes[i]?.defaults?.() as never) + for (const key of ['renderer', 'system', 'bakeReplaceRenderer', 'floorplan']) { + expect(def[key]).toBeDefined() + expect(def[key]).toBe(full[key]) + } + } + }) + + test('render definitions carry no editing fields; the root definitions still do', () => { + for (const [i, render] of renderNodes.entries()) { + expect(EDIT_FIELDS.filter((field) => field in render)).toEqual([]) + const full = fullNodes[i] as unknown as Record + for (const field of ['parametrics', 'tool', 'preview', 'toolHints', 'snapProfile']) { + expect(full[field]).toBeDefined() + } + } + }) +}) diff --git a/src/render.ts b/src/render.ts new file mode 100644 index 0000000..ceb661c --- /dev/null +++ b/src/render.ts @@ -0,0 +1,29 @@ +import type { AnyNodeDefinition, Plugin } from '@pascal-app/core' +import { treeRenderDefinition } from './definition.render' +import { flowerRenderDefinition } from './flower-definition.render' +import { grassRenderDefinition } from './grass-definition.render' + +/** + * Render-only entry (`@pascal-app/plugin-trees/render`) for read-only viewers. + * Same plugin id, contract version, kinds, schemas, defaults and bake policy as + * `treesPlugin`, without placement, inspector, gizmos, the Nature panel or + * anything that imports `@pascal-app/editor`. `bun run check:render` holds that + * boundary. + */ +export const treesRenderPlugin: Plugin = { + id: 'pascal:trees', + apiVersion: 1, + nodes: [ + treeRenderDefinition as unknown as AnyNodeDefinition, + flowerRenderDefinition as unknown as AnyNodeDefinition, + grassRenderDefinition as unknown as AnyNodeDefinition, + ], +} + +// No re-export from './geometry': ez-tree touches `document` at module scope. +export { treeRenderDefinition } from './definition.render' +export { flowerRenderDefinition } from './flower-definition.render' +export { FlowerNode, FlowerPreset } from './flower-schema' +export { grassRenderDefinition } from './grass-definition.render' +export { GrassNode, GrassPreset } from './grass-schema' +export { TreeNode, TreePreset } from './schema'