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 @@ -16,3 +16,4 @@ jobs:
- run: bun install --frozen-lockfile
- run: bun run check-types
- run: bun test
- run: bun run check:render
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
7 changes: 6 additions & 1 deletion 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 @@ -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"
Expand Down
61 changes: 61 additions & 0 deletions scripts/check-render-entry.ts
Original file line number Diff line number Diff line change
@@ -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<Hit[]> {
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).`,
)
53 changes: 53 additions & 0 deletions src/definition.edit.ts
Original file line number Diff line number Diff line change
@@ -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<TreeNode> {
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<typeof TreeNode> = {
snapProfile: 'item',
parametrics: treeParametrics,
handles: [treeRotateHandle()],
preview: () => import('./preview'),
tool: () => import('./tool'),
toolHints: [
{ key: 'Left click', label: 'Plant tree' },
{ key: 'Esc', label: 'Stop' },
],
}
101 changes: 101 additions & 0 deletions src/definition.render.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import type { NodeDefinition } from '@pascal-app/core'
import { buildTreeFloorplan, treeTrunkRadius } from './floorplan'
import { TreeNode } from './schema'

type TreeDefinition = NodeDefinition<typeof TreeNode> & Record<string, unknown>

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.',
},
}
Loading
Loading