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 .codex/rules/creating-rules.md
1 change: 1 addition & 0 deletions .codex/rules/events.md
1 change: 1 addition & 0 deletions .codex/rules/layers.md
1 change: 1 addition & 0 deletions .codex/rules/node-schemas.md
1 change: 1 addition & 0 deletions .codex/rules/renderers.md
1 change: 1 addition & 0 deletions .codex/rules/scene-registry.md
1 change: 1 addition & 0 deletions .codex/rules/selection-managers.md
1 change: 1 addition & 0 deletions .codex/rules/spatial-queries.md
1 change: 1 addition & 0 deletions .codex/rules/systems.md
1 change: 1 addition & 0 deletions .codex/rules/tools.md
1 change: 1 addition & 0 deletions .codex/rules/viewer-isolation.md
119 changes: 119 additions & 0 deletions .codex/skills/review-architecture/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
---
name: review-architecture
description: Review a PR against the Pascal architectural rules — layer boundaries (core/viewer/editor), systems/renderers/tools separation, hook hygiene (useEditor/useScene/useViewer), and selector performance. Use when the user asks to review a PR, audit a branch, or check that changes respect the codebase's architecture.
allowed-tools: Bash(git *) Bash(gh *) Read Grep Glob
---

Architectural review for Pascal PRs. The user will provide a PR URL, branch name, or ask to review the current branch.

## 1. Load the rules (required — do not skip)

Read these before reviewing any diff. They are the source of truth, not your training data:

- `.codex/rules/systems.md` — core systems vs viewer systems, what each may do
- `.codex/rules/renderers.md` — renderer responsibilities and prohibitions
- `.codex/rules/tools.md` — editor tools live only in `apps/editor/components/tools/`
- `.codex/rules/viewer-isolation.md` — viewer must stay editor-agnostic
- `.codex/rules/layers.md`
- `.codex/rules/selection-managers.md`
- `.codex/rules/scene-registry.md`
- `.codex/rules/spatial-queries.md`
- `.codex/rules/node-schemas.md`
- `.codex/rules/events.md`

Only the first four are required on every review; read the rest when the diff touches their subject area.

## 2. Fetch the diff

```bash
# If the user gave a PR URL or number:
gh pr diff <pr-number-or-url>

# If reviewing the current branch:
git diff main...HEAD
```

Also list changed files so you can map each to the relevant rule:

```bash
gh pr view <pr> --json files --jq '.files[].path'
# or
git diff --name-only main...HEAD
```

## 3. Layer classification — do this BEFORE the checklist

For every new file, new type, new store field, or new exported helper introduced by the diff, answer one question: **which layer does this belong to — core, viewer, or editor?** If the answer is "editor" but the code lives in `packages/core` or `packages/viewer` (or vice versa), flag it as a **blocker**. This is the most common and most damaging class of violation, and the checklist below won't reliably catch it on its own — do this pass explicitly.

### The three layers and what they own

**`packages/core` — domain data + pure logic.**
Owns: node schemas, the scene store (`useScene`), live transforms store, core systems (wall mitering, slab polygons, space detection), event bus, plain 2D/3D math helpers, `sceneRegistry`. Consumed by every downstream package, including read-only embeds. Must not know about: Three.js/R3F, `packages/viewer`, `apps/editor`, any rendering or UI concept, any tool/mode/phase concept, or any *view*-specific concept (floorplan, paint preview, cursor indicators, selection outline styling, etc.).

**`packages/viewer` — the 3D canvas, shippable standalone.**
Owns: `<Viewer>`, renderers, viewer systems (cutouts, zones, level positions, scans), the viewer store (`useViewer`) *for genuine presentation state only* (selection path, camera/level/wall/view modes, theme, display toggles, hover id). Consumed by both the editor and the read-only `/viewer/[id]` route. Must not know about: editor state (`useEditor`, tools, phases, modes), editor-only names baked into presentation modes (`'delete'`, `'paint-ready'`), editor-only state types (material preview, active paint target, floorplan anything).

**`apps/editor` (and editor-scoped packages) — the editing experience.**
Owns: tools, `useEditor`, action menus, panels, the floorplan panel and its helpers, paint mode, selection-manager phase/mode logic, cursor badges, command palette, keyboard shortcuts — anything absent from the read-only viewer route. Injects itself into `<Viewer>` via children and props, never the reverse.

### Five triggers that mean "this is probably editor"

1. **Would the read-only `/viewer/[id]` route need this?** If no, it belongs in `apps/editor`.
2. **Does the name contain an editor-specific word?** (`Floorplan`, `Paint…`, `Draft…`, `Marquee`, `CursorBadge`, `HoverMode`, `…Tool`, `Moving…`, `Curving…`.) Default to editor and justify loudly if it's anywhere else.
3. **Does the type or field reference a tool/mode/phase vocabulary?** (`'delete'`, `'paint-ready'`, `'material-paint'`, `'site'`/`'structure'`/`'furnish'`, `'build'`/`'edit'`.) Belongs in `useEditor`, not `useViewer` or core.
4. **Does the helper compute something only a 2D editor view needs?** (Floorplan transforms, measurement offsets, SVG path builders, marquee bounds scoped to floorplan.) Editor. Generic 2D geometry that any view could use (polygon math, rotation, clamping, line thickening) can live in core *as long as its names are generic* — no `Floorplan` prefix.
5. **Does a new store field have a setter that no part of the target layer ever calls?** (e.g. `setMaterialPreview` in `useViewer` that only the editor would ever invoke.) That's a layering smell — the state belongs in the caller's layer.

Write the classification down before writing findings. If core gains "Floorplan" types, or the viewer gains paint-mode vocabulary, or a renderer grows editor awareness — those are the blockers to lead with, not downstream symptoms.

## 4. Review checklist

### A. Layer boundaries
- `packages/viewer/**` does not import from `apps/editor` or reference `useEditor`, tool state, phase, or mode.
- `packages/core/**` does not import Three.js, react-three-fiber, or anything from `packages/viewer` / `apps/editor`.
- `packages/core/**` does not introduce types or helpers named after an editor view (`Floorplan*`, `Paint*`, `Draft*`). Generic plan-geometry helpers are fine; view-specific vocabulary is not.
- Renderers contain no geometry generation or domain logic — that belongs in a system.
- Tools mutate `useScene` (committed state) and `useLiveTransforms` (ephemeral drag state); direct `sceneRegistry` mesh transforms are allowed only under the live-drag exception in `.codex/rules/tools.md`. No business logic, no imports from `packages/viewer`.

### B. Hook hygiene (`useEditor`, `useScene`, `useViewer`)
- Stores hold state + setters only. No business logic, side effects, async work, or derived computations inside the store definition.
- Derived values belong in selectors or systems, not in the store body.
- No cross-store coupling: a store's action should not call another store's actions inside itself.
- New state added to `useViewer` must be presentation-only (selection, camera, level mode, display toggles). Editor-only state (active tool, phase, edit mode, paint preview, floorplan state) goes in `useEditor`.

### C. Selector performance
- Top-level components (pages, layouts, providers, `<Viewer>` siblings) must not subscribe to large or frequently-changing slices — e.g. `useScene(s => s.nodes)`, `useScene(s => s)`. Flag these: they re-render the whole subtree on every mutation.
- Selectors that return new object or array references each call (e.g. `s => ({ a: s.a, b: s.b })`, `s => s.items.filter(...)`) without a custom equality function (shallow or custom) are re-render hazards.
- Prefer subscribing by ID deep in the tree (one node per renderer) over subscribing to the full collection high up.

### D. Separation of concerns
- Viewer and core stay unaware of editor-specific concepts (tools, phases, active modes, editor UI state, view-specific helpers).
- Editor-only overlays and systems are injected as children of `<Viewer>`, not added inside the viewer package.
- New node types added correctly: schema → core system (if derived geometry) → viewer renderer → register in `NodeRenderer`.

## 5. Output format

Group findings by severity:

- **Blocker** — violates a rule in `.codex/rules` or breaks a layer boundary. Must be fixed before merge.
- **Suggestion** — likely problem, worth discussing. Not a hard block.
- **Nit** — minor, optional.

For each finding, include:

1. File and line: `path/to/file.ts:42`
2. The offending snippet (short — 1–5 lines)
3. The rule it violates, linked to the rule file (e.g. `.codex/rules/viewer-isolation.md`)
4. A concrete proposed fix

Skip formatting, import ordering, and anything CI already covers.

If the PR fully complies, say so explicitly — do not invent nits to appear thorough.

## 6. Final summary

End with:

- Blocker count, suggestion count, nit count
- One-sentence verdict: ready to merge / needs changes / needs discussion
- If blockers exist, list the files the author should open first
49 changes: 49 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Pascal Agent Instructions

This repository uses shared architecture rules for AI assistants. Treat the rule files as the source of truth for architecture-sensitive work.

## Required Rule Sources

The canonical rules live in `.cursor/rules/*.mdc`.

Claude-compatible paths are exposed in `.claude/rules/*.md`.
Codex-compatible paths are exposed in `.codex/rules/*.md`.

Both should point to the same Cursor rule sources so Claude and Codex review the exact same rules.

## Architecture Rules

Read the relevant rules before making or reviewing changes in these areas:

- `.codex/rules/systems.md` — core systems vs viewer systems, what each may do
- `.codex/rules/renderers.md` — renderer responsibilities and prohibitions
- `.codex/rules/tools.md` — editor tools live only in `apps/editor/components/tools/`
- `.codex/rules/viewer-isolation.md` — viewer must stay editor-agnostic
- `.codex/rules/layers.md`
- `.codex/rules/selection-managers.md`
- `.codex/rules/scene-registry.md`
- `.codex/rules/spatial-queries.md`
- `.codex/rules/node-schemas.md`
- `.codex/rules/events.md`

For architecture reviews, the first four are always required. Read the remaining rules when the diff touches their subject area.

## Layer Boundaries

`packages/core` owns domain data and pure logic. It must not import Three.js, `packages/viewer`, `apps/editor`, rendering/UI concepts, tools, modes, phases, or view-specific concepts such as floorplan or paint preview.

`packages/viewer` owns the standalone 3D canvas, renderers, viewer systems, and genuine presentation state. It must not know about `useEditor`, editor tools, phases, modes, paint mode, floorplan state, or editor-only presentation vocabulary.

`apps/editor` owns the editing experience: tools, `useEditor`, panels, floorplan helpers, paint mode, keyboard shortcuts, command palette, action menus, cursor badges, and editor-only overlays. Editor features are injected into `<Viewer>` via props and children.

## Review Expectations

When reviewing architecture changes:

1. Classify every new file, type, store field, and exported helper as core, viewer, or editor before writing findings.
2. Lead with layer-boundary blockers.
3. Check hook hygiene for `useEditor`, `useScene`, and `useViewer`.
4. Check selector performance for broad subscriptions and selectors that allocate fresh references.
5. Skip formatting and import ordering unless they hide a real behavior or architecture issue.

Use `.codex/skills/review-architecture/SKILL.md` when the user asks Codex to review a PR, audit a branch, or check architecture compliance.
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 6 additions & 5 deletions packages/core/src/events/bus.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ThreeEvent } from '@react-three/fiber'
import type { Object3D } from 'three'
import mitt from 'mitt'
import type { Object3D } from 'three'
import type {
BuildingNode,
CeilingNode,
Expand All @@ -12,6 +12,7 @@ import type {
RoofSegmentNode,
SiteNode,
SlabNode,
SpawnNode,
StairNode,
StairSegmentNode,
WallNode,
Expand Down Expand Up @@ -53,6 +54,7 @@ export type BuildingEvent = NodeEvent<BuildingNode>
export type LevelEvent = NodeEvent<LevelNode>
export type ZoneEvent = NodeEvent<ZoneNode>
export type SlabEvent = NodeEvent<SlabNode>
export type SpawnEvent = NodeEvent<SpawnNode>
export type CeilingEvent = NodeEvent<CeilingNode>
export type RoofEvent = NodeEvent<RoofNode>
export type RoofSegmentEvent = NodeEvent<RoofSegmentNode>
Expand Down Expand Up @@ -102,10 +104,8 @@ export interface ThumbnailGenerateEvent {

export interface CameraControlFitSceneEvent {
/**
* XZ-plane axis-aligned bounds of the scene's geometry, computed from the
* scene graph (see `@pascal-app/editor`'s `computeSceneBoundsXZ`). The
* viewer's camera-controls listener frames the camera onto this box.
* Omitted values fall back to the camera's default pose.
* XZ-plane axis-aligned bounds for camera framing. Omitted values let the
* listener choose its default framing pose.
*/
bounds?: {
min: [number, number]
Expand Down Expand Up @@ -160,6 +160,7 @@ type EditorEvents = GridEvents &
NodeEvents<'level', LevelEvent> &
NodeEvents<'zone', ZoneEvent> &
NodeEvents<'slab', SlabEvent> &
NodeEvents<'spawn', SpawnEvent> &
NodeEvents<'ceiling', CeilingEvent> &
NodeEvents<'roof', RoofEvent> &
NodeEvents<'roof-segment', RoofSegmentEvent> &
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/hooks/scene-registry/scene-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const sceneRegistry = {
fence: new Set<string>(),
item: new Set<string>(),
slab: new Set<string>(),
spawn: new Set<string>(),
zone: new Set<string>(),
roof: new Set<string>(),
'roof-segment': new Set<string>(),
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type {
RoofSegmentEvent,
SiteEvent,
SlabEvent,
SpawnEvent,
StairEvent,
StairSegmentEvent,
WallEvent,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/schema/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export { ScanNode } from './nodes/scan'
// Nodes
export { SiteNode } from './nodes/site'
export { SlabNode } from './nodes/slab'
export { SpawnNode } from './nodes/spawn'
export {
getEffectiveStairSurfaceMaterial,
StairNode,
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/schema/nodes/door.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export const DoorNode = BaseNode.extend({
}).describe(dedent`Door node - a parametric door placed on a wall
- position: center of the door in wall-local coordinate system (Y = height/2, always at floor)
- segments: rows stacked top to bottom, each defining its own columnRatios
- type 'empty' = flush flat fill, 'panel' = raised/recessed panel, 'glass' = glazed
- type 'empty' = no leaf fill for that segment, 'panel' = raised/recessed panel, 'glass' = glazed
- hingesSide/swingDirection: which way the door opens
- doorCloser/panicBar: commercial and emergency hardware options
`)
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/schema/nodes/level.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { ItemNode } from './item'
import { RoofNode } from './roof'
import { ScanNode } from './scan'
import { SlabNode } from './slab'
import { SpawnNode } from './spawn'
import { StairNode } from './stair'
import { WallNode } from './wall'
import { ZoneNode } from './zone'
Expand All @@ -28,6 +29,7 @@ export const LevelNode = BaseNode.extend({
StairNode.shape.id,
ScanNode.shape.id,
GuideNode.shape.id,
SpawnNode.shape.id,
]),
)
.default([]),
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/schema/nodes/spawn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'

export const SpawnNode = BaseNode.extend({
id: objectId('spawn'),
type: nodeType('spawn'),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
rotation: z.number().default(0),
})

export type SpawnNode = z.infer<typeof SpawnNode>
2 changes: 2 additions & 0 deletions packages/core/src/schema/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { RoofSegmentNode } from './nodes/roof-segment'
import { ScanNode } from './nodes/scan'
import { SiteNode } from './nodes/site'
import { SlabNode } from './nodes/slab'
import { SpawnNode } from './nodes/spawn'
import { StairNode } from './nodes/stair'
import { StairSegmentNode } from './nodes/stair-segment'
import { WallNode } from './nodes/wall'
Expand All @@ -33,6 +34,7 @@ export const AnyNode = z.discriminatedUnion('type', [
StairSegmentNode,
ScanNode,
GuideNode,
SpawnNode,
WindowNode,
DoorNode,
])
Expand Down
12 changes: 7 additions & 5 deletions packages/core/src/store/actions/node-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,27 +238,29 @@ export const createNodesAction = (
const nextRootIds = [...state.rootNodeIds]

for (const { node, parentId } of ops) {
const effectiveParentId = parentId ?? (node.parentId as AnyNodeId | null) ?? null

// 1. Assign parentId to the child (Safe because BaseNode has parentId)
const newNode = {
...node,
parentId: parentId ?? null,
parentId: effectiveParentId,
}

nextNodes[newNode.id] = newNode

// 2. Update the Parent's children list
if (parentId && nextNodes[parentId]) {
const parent = nextNodes[parentId]
if (effectiveParentId && nextNodes[effectiveParentId]) {
const parent = nextNodes[effectiveParentId]

// Type Guard: Check if the parent node is a container that supports children
if ('children' in parent && Array.isArray(parent.children)) {
nextNodes[parentId] = {
nextNodes[effectiveParentId] = {
...parent,
// Use Set to prevent duplicate IDs if createNode is called twice
children: Array.from(new Set([...parent.children, newNode.id])) as any, // We don't verify child types here
}
}
} else if (!parentId) {
} else if (!effectiveParentId) {
// 3. Handle Root nodes
if (!nextRootIds.includes(newNode.id)) {
nextRootIds.push(newNode.id)
Expand Down
Loading
Loading