Files
editor/apps/editor
a590747748 Adjustments pass: three 0.185, undo/cancel semantics, guide & panel fixes (#496)
* fix(editor): only start handle drags on primary button

Right-click over a rotation/move/resize handle started the gesture and
stopPropagation()'d, fighting the camera orbit. Guard every gesture
starter (shared useHandleDrag, group rotate gizmo, wall endpoint/height/
move, fence move, roof trim) with event.button !== 0 before it swallows
the event.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(viewer): default units from timezone/locale until user picks

Derive the metric/imperial default from the IANA timezone (US, Liberia,
Myanmar zones -> imperial; anything else -> metric), falling back to an
explicit locale region subtag only when no timezone resolves. Timezone
tracks actual location, unlike navigator.language where en-US is a common
default far outside the US. The unit is only persisted once the user
explicitly sets it, so an untouched preference keeps tracking location;
existing persisted values are treated as explicit and left alone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(viewer): toggle shadows via renderer.shadowMap.enabled, not castShadow

Flipping a light's castShadow at runtime crashes three r184's WebGPU
renderer: toggling off disposes the shadow map's GPU texture, but the
node builder cache evicts with the post-toggle key, so the shadows-on
entry survives still referencing the destroyed texture. Re-enabling
reuses that stale state and every frame submit fails with
GPUValidationError ("Invalid CommandBuffer from CommandEncoder").

Keep castShadow static and drive the user-facing toggle through the
Canvas shadows prop (renderer.shadowMap.enabled), which rebuilds
materials without disposing shadow resources.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(core,editor): floor undo history at scene load

Undo could step back past the scene load into the pre-load (empty)
state, wiping the whole project — which autosave would then persist.
Two defects: clearSceneHistory() had zero call sites, so every load left
the empty pre-load state in zundo's pastStates; and setScene wrote the
store twice, recording a half-normalized intermediate as a second undo
target.

- applySceneGraphToEditor, JSON import, and reset-to-default now clear
  history so the loaded scene is the undo floor
- setScene collapses to a single tracked write (final state identical)
- clearSceneHistory also resumes tracking so a load landing inside a
  pause window can't strand undo recording off

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* build(three): upgrade runtime to 0.185.1, pin @types/three at 0.184

r185 renames directionToColor/colorToDirection to packNormalToRGB/
unpackRGBToNormal and splits SSGI's packed rgba output into separate AO
(getAONode, single channel) and GI (getGINode) textures; wind-node's
positionLocal reads become positionGeometry.

@types/three stays at 0.184.1: the 0.185 typings send tsgo's inference
into unbounded allocation (microsoft/typescript-go#2125 class — it ate
~70GB/90s and OOM-killed the machine). viewer/lib/tsl-compat.ts bridges
the two renamed TSL exports with 0.184-typed signatures; drop it and the
pin together once tsgo copes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(viewer): gate .ktx2 preset-texture loads on detectSupport

KTX2Loader.load throws before detectSupport has run, and materials
created while a standalone capture canvas's renderer was still
initializing cached themselves permanently texture-less — fabric slots
rendered white in item thumbnails. .ktx2 loads now await whenKtx2Ready()
(resolved by the first successful ensureKtx2Support), which is exported
so hosts with standalone canvases can arm it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(viewer): accept host-supplied country for the unit default

applyCountryUnitDefault lets the host app feed an authoritative
IP-derived country (e.g. Vercel's x-vercel-ip-country) into the unit
default. Stronger signal than the timezone heuristic applied at store
creation, still never overrides an explicit user choice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(editor): box select starts over locked guide images

A locked guide's hit-rect swallowed pointer-down via stopPropagation, so
marquee selection couldn't start on top of it. Locked guides now let the
event bubble to the svg root; click-to-select and the unlock affordance
still work because a non-drag release fires onClick as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(editor): live rotation readout while rotating a guide image

Rotating a guide with the 2D handles gave no angle feedback. Reuse the
registry layer's RotationAngleOverlay (wedge + degree chip) for guide
rotate drags: sweeps from the grabbed corner's bearing at grab to its
current snapped bearing, suppressed under ~0.5deg so a fresh grab doesn't
flash a sliver.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(editor): lingo free-text input in the guide set-scale dialog

The real-length field accepts natural measurement text via
@pascal-app/lingo — 5'11", 180cm, 1m80, 12ft — parsed in the dropdown's
unit (a bare number still means that unit, a typed unit wins). A faint
'= 1.80 m' hint previews non-trivial input, unparseable text gets a
clear error, and the odd onBlur force-reset to 0.0001 is gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(editor): cmd+z mid-interaction cancels the gesture instead of undoing

Undo pressed while the mouse is mid-action (moving, drawing, dragging a
handle) used to history-jump under the live pointer — stale carry, half
gestures committing against a rewound scene. Now it reads as 'abort this
action', exactly like Escape:

- the global undo/redo arms first route through the tool:cancel path
  (covers build drafts, placement ghosts, move tools) and skip the
  history jump when anything was in flight (consumed, scope-active, or
  inputDragging);
- pointer drags that only knew pointercancel (generic handle drags,
  group rotate, wall side/height handles, roof trim) gain the same
  capture-phase Escape/cmd+z keydown the group-move drags already had —
  fixing Escape for them too;
- the existing capture-phase handlers (3D/2D group move, 2D registry
  move overlay) additionally accept cmd+z as cancel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(editor): box select arms over any guide image that won't drag

Follow-up to cd830279, which only let LOCKED guides bubble pointer-down.
An unlocked, unselected guide also swallowed the event for nothing (no
translate drag starts), so marquee selection could never start on top of
it. Now only the one case that uses the event consumes it — selected +
unlocked → translate drag — and everything else bubbles to the svg root.
Click-to-select still works: a non-drag release never crosses the
box-select threshold, so the trailing click fires the guide's onClick.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(editor): cmd+z during draft placement cancels instead of undoing

The preset/item placement flow (useDraftNode + placement coordinator)
registers no interaction scope and holds no pointer, so the cmd+z cancel
guard from c699d74e saw it as idle and history-jumped mid-placement.
Paused scene history is the universal tell — the draft cycle (and every
adopted-move session) keeps temporal paused for the whole gesture, and
an undo against a paused store lands on a stale baseline anyway. Treat
!isTracking as in-flight.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(editor): cmd+z mid-placement completes the cancel, not just skips undo

4ffda723 stopped the history jump during preset/item placement but left
the draft alive: the item tool passes no coordinator onCancel — it is
Escape's fall-through (switch to select, unmount the tool) that actually
destroys the draft. Extract that fall-through and run it from the cmd+z
path too whenever a gesture is live and nothing consumed tool:cancel,
so cmd+z now behaves exactly like Escape end to end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(editor): node selection clears a lingering reference selection

Selecting a guide clears the node selection (handleGuideSelect), but the
reverse was never wired: clicking a wall with a floorplan reference
selected left selectedReferenceId set, and the panel manager's
reference-first priority kept showing the floorplan panel until it was
closed by hand. PanelManager now drops the stale reference the moment a
scene selection (nodes or zone) appears.

Also: the inspector's expanded state is shared across panel swaps by
design, but it survived close/reopen too — deselecting everything now
resets it, so a fresh selection opens the panel collapsed again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugin-trees): wind displacement reads positionLocal, not positionGeometry

The three-0.185 migration renamed positionLocal to positionGeometry in
the wind nodes, but positionLocal was never removed in r185 — and the
two are not interchangeable here. NodeMaterial.setupPosition applies the
instance transform by mutating positionLocal, then overwrites it with
positionNode's output; reading raw positionGeometry therefore discarded
every instance matrix — leaf cards rendered unscaled at tree-local
coordinates (a giant canopy filling the sky) and grass/flower instances
collapsed invisibly. Reading positionLocal (instance transform included)
restores r184 behavior exactly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 13:26:35 -04:00
..
2026-02-19 01:19:52 -05:00
2026-01-14 09:03:07 +09:00

Pascal Editor

A 3D building editor built with React Three Fiber and WebGPU.

Repository Architecture

This is a Turborepo monorepo with three main packages:

editor/
├── apps/
│   └── editor/          # Next.js application (this package)
├── packages/
│   ├── core/            # Schema definitions, state management, systems
│   └── viewer/          # 3D rendering components

Separation of Concerns

Package Responsibility
@pascal-app/core Node schemas, scene state (Zustand), systems (geometry generation), spatial queries, event bus
@pascal-app/viewer 3D rendering via React Three Fiber, default camera/controls, post-processing
apps/editor UI components, tools, custom behaviors, editor-specific systems

The viewer renders the scene with sensible defaults. The editor extends it with interactive tools, selection management, and editing capabilities.

Stores

Each package has its own Zustand store for managing state:

Store Package Responsibility
useScene @pascal-app/core Scene data: nodes, root IDs, dirty nodes, CRUD operations. Persisted to IndexedDB with undo/redo via Zundo.
useViewer @pascal-app/viewer Viewer state: current selection (building/level/zone IDs), level display mode (stacked/exploded/solo), camera mode.
useEditor apps/editor Editor state: active tool, structure layer visibility, panel states, editor-specific preferences.

Access patterns:

// Subscribe to state changes (React component)
const nodes = useScene((state) => state.nodes)
const levelId = useViewer((state) => state.selection.levelId)
const activeTool = useEditor((state) => state.tool)

// Access state outside React (callbacks, systems)
const node = useScene.getState().nodes[id]
useViewer.getState().setSelection({ levelId: 'level_123' })

Core Concepts

Nodes

Nodes are the data primitives that describe the 3D scene. All nodes extend BaseNode:

BaseNode {
  id: string              // Auto-generated with type prefix (e.g., "wall_abc123")
  type: string            // Discriminator for type-safe handling
  parentId: string | null // Parent node reference
  visible: boolean
  camera?: Camera         // Optional saved camera position
  metadata?: JSON         // Arbitrary metadata (e.g., { isTransient: true })
}

Node Hierarchy:

Site
└── Building
    └── Level
        ├── Wall → Item (doors, windows)
        ├── Slab
        ├── Ceiling → Item (lights)
        ├── Roof
        ├── Zone
        ├── Scan (3D reference)
        └── Guide (2D reference)

Nodes are stored in a flat dictionary (Record<id, Node>), not a nested tree. Parent-child relationships are defined via parentId and children arrays.


Scene State (Zustand Store)

The scene is managed by a Zustand store in @pascal-app/core:

useScene.getState() = {
  nodes: Record<id, AnyNode>,  // All nodes
  rootNodeIds: string[],       // Top-level nodes (sites)
  dirtyNodes: Set<string>,     // Nodes pending system updates

  createNode(node, parentId),
  updateNode(id, updates),
  deleteNode(id),
}

Middleware:

  • Persist - Saves to IndexedDB (excludes transient nodes)
  • Temporal (Zundo) - Undo/redo with 50-step history

Scene Registry

The registry maps node IDs to their Three.js objects for fast lookup:

sceneRegistry = {
  nodes: Map<id, Object3D>,    // ID → 3D object
  byType: {
    wall: Set<id>,
    item: Set<id>,
    zone: Set<id>,
    // ...
  }
}

Renderers register their refs using the useRegistry hook:

const ref = useRef<Mesh>(null!)
useRegistry(node.id, 'wall', ref)

This allows systems to access 3D objects directly without traversing the scene graph.


Node Renderers

Renderers are React components that create Three.js objects for each node type:

SceneRenderer
└── NodeRenderer (dispatches by type)
    ├── BuildingRenderer
    ├── LevelRenderer
    ├── WallRenderer
    ├── SlabRenderer
    ├── ZoneRenderer
    ├── ItemRenderer
    └── ...

Pattern:

  1. Renderer creates a placeholder mesh/group
  2. Registers it with useRegistry
  3. Systems update geometry based on node data

Example (simplified):

const WallRenderer = ({ node }) => {
  const ref = useRef<Mesh>(null!)
  useRegistry(node.id, 'wall', ref)

  return (
    <mesh ref={ref}>
      <boxGeometry args={[0, 0, 0]} />  {/* Replaced by WallSystem */}
      <meshStandardMaterial />
      {node.children.map(id => <NodeRenderer key={id} nodeId={id} />)}
    </mesh>
  )
}

Systems

Systems are React components that run in the render loop (useFrame) to update geometry and transforms. They process dirty nodes marked by the store.

Core Systems (in @pascal-app/core):

System Responsibility
WallSystem Generates wall geometry with mitering and CSG cutouts for doors/windows
SlabSystem Generates floor geometry from polygons
CeilingSystem Generates ceiling geometry
RoofSystem Generates roof geometry
ItemSystem Positions items on walls, ceilings, or floors (slab elevation)

Viewer Systems (in @pascal-app/viewer):

System Responsibility
LevelSystem Handles level visibility and vertical positioning (stacked/exploded/solo modes)
ScanSystem Controls 3D scan visibility
GuideSystem Controls guide image visibility

Processing Pattern:

useFrame(() => {
  for (const id of dirtyNodes) {
    const obj = sceneRegistry.nodes.get(id)
    const node = useScene.getState().nodes[id]

    // Update geometry, transforms, etc.
    updateGeometry(obj, node)

    dirtyNodes.delete(id)
  }
})

Dirty Nodes

When a node changes, it's marked as dirty in useScene.getState().dirtyNodes. Systems check this set each frame and only recompute geometry for dirty nodes.

// Automatic: createNode, updateNode, deleteNode mark nodes dirty
useScene.getState().updateNode(wallId, { thickness: 0.2 })
// → wallId added to dirtyNodes
// → WallSystem regenerates geometry next frame
// → wallId removed from dirtyNodes

Manual marking:

useScene.getState().dirtyNodes.add(wallId)

Event Bus

Inter-component communication uses a typed event emitter (mitt):

// Node events
emitter.on('wall:click', (event) => { ... })
emitter.on('item:enter', (event) => { ... })
emitter.on('zone:context-menu', (event) => { ... })

// Grid events (background)
emitter.on('grid:click', (event) => { ... })

// Event payload
NodeEvent {
  node: AnyNode
  position: [x, y, z]
  localPosition: [x, y, z]
  normal?: [x, y, z]
  stopPropagation: () => void
}

Spatial Grid Manager

Handles collision detection and placement validation:

spatialGridManager.canPlaceOnFloor(levelId, position, dimensions, rotation)
spatialGridManager.canPlaceOnWall(wallId, t, height, dimensions)
spatialGridManager.getSlabElevationAt(levelId, x, z)

Used by item placement tools to validate positions and calculate slab elevations.


Editor Architecture

The editor extends the viewer with:

Tools

Tools are activated via the toolbar and handle user input for specific operations:

  • SelectTool - Selection and manipulation
  • WallTool - Draw walls
  • ZoneTool - Create zones
  • ItemTool - Place furniture/fixtures
  • SlabTool - Create floor slabs

Selection Manager

The editor uses a custom selection manager with hierarchical navigation:

Site → Building → Level → Zone → Items

Each depth level has its own selection strategy for hover/click behavior.

Editor-Specific Systems

  • ZoneSystem - Controls zone visibility based on level mode
  • Custom camera controls with node focusing

Data Flow

User Action (click, drag)
       ↓
Tool Handler
       ↓
useScene.createNode() / updateNode()
       ↓
Node added/updated in store
Node marked dirty
       ↓
React re-renders NodeRenderer
useRegistry() registers 3D object
       ↓
System detects dirty node (useFrame)
Updates geometry via sceneRegistry
Clears dirty flag

Technology Stack

  • React 19 + Next.js 15
  • Three.js (WebGPU renderer)
  • React Three Fiber + Drei
  • Zustand (state management)
  • Zod (schema validation)
  • Zundo (undo/redo)
  • three-bvh-csg (Boolean geometry operations)

Getting Started

# Install dependencies
pnpm install

# Run development server
pnpm dev

# Open http://localhost:3000

Key Files

Path Description
packages/core/src/schema/ Node type definitions (Zod schemas)
packages/core/src/hooks/use-scene.ts Scene state store
packages/core/src/hooks/scene-registry/ 3D object registry
packages/core/src/systems/ Geometry generation systems
packages/viewer/src/components/renderers/ Node renderers
packages/viewer/src/components/viewer/ Main Viewer component
apps/editor/components/tools/ Editor tools
apps/editor/store/ Editor-specific state