Merge remote-tracking branch 'origin/main' into feat/mcp-server

This commit is contained in:
Aymeric Rabot
2026-04-27 17:22:14 -04:00
82 changed files with 4729 additions and 1063 deletions
+119
View File
@@ -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:
- `.claude/rules/systems.md` — core systems vs viewer systems, what each may do
- `.claude/rules/renderers.md` — renderer responsibilities and prohibitions
- `.claude/rules/tools.md` — editor tools live only in `apps/editor/components/tools/`
- `.claude/rules/viewer-isolation.md` — viewer must stay editor-agnostic
- `.claude/rules/layers.md`
- `.claude/rules/selection-managers.md`
- `.claude/rules/scene-registry.md`
- `.claude/rules/spatial-queries.md`
- `.claude/rules/node-schemas.md`
- `.claude/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 `.claude/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 `.claude/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 — 15 lines)
3. The rule it violates, linked to the rule file (e.g. `.claude/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
+7 -3
View File
@@ -60,11 +60,15 @@ export function MyTool() {
## Rules ## Rules
- **Tools only mutate `useScene`** — they do not call Three.js APIs directly. - **Tools mutate `useScene` for committed changes and `useLiveTransforms` for ephemeral drag state.** A tool's end-of-interaction write (click-to-commit, release-to-commit) goes to `useScene` and is captured in undo history. Per-mouse-move previews go to `useLiveTransforms` so history and subscribers aren't spammed.
- **Live-drag exception for direct mesh transforms.** During an active drag a tool may apply a transform offset directly to `sceneRegistry.nodes.get(id).position`/`rotation`/`scale` *when and only when* the same offset is mirrored into `useLiveTransforms` for that node. This exception exists because the 3D renderers don't reconcile `useLiveTransforms` onto `mesh.position` yet; once a `LiveTransformSystem` does that, this exception goes away. Conditions:
- The mesh offset must mirror the `useLiveTransforms` entry (same delta on both), so anything reading `useLiveTransforms` sees the same preview as the 3D view.
- The offset must be cleared on tool unmount, cancel, *and* commit — both `mesh.position.set(0, 0, 0)` and `useLiveTransforms.clear(id)`.
- The tool must not generate or mutate geometry in this path — only transform writes. Geometry generation still belongs in a core system.
- **No business logic in tools** — delegate geometry/constraint rules to core systems. - **No business logic in tools** — delegate geometry/constraint rules to core systems.
- **Preview geometry is local** — transient meshes shown while a tool is active live in the tool component, not in the scene store. - **Preview geometry is local** — transient meshes shown while a tool is active live in the tool component, not in the scene store.
- **Clean up on unmount** — remove any pending/incomplete nodes when the tool unmounts. - **Clean up on unmount** — remove any pending/incomplete nodes *and* any live transforms/mesh offsets when the tool unmounts.
- **Tools must not import from `@pascal-app/viewer`** — use the scene store and core hooks only. - **Tools must not import from `@pascal-app/viewer`** — use the scene store and core hooks only. `sceneRegistry` is exported from `@pascal-app/core` and is the allowed door into the Three.js graph for the narrow purposes above.
- Each tool should handle a single, well-scoped interaction. Split complex tools (e.g. "draw + move") into separate components selected by `useEditor`. - Each tool should handle a single, well-scoped interaction. Split complex tools (e.g. "draw + move") into separate components selected by `useEditor`.
## Adding a New Tool ## Adding a New Tool
+1
View File
@@ -0,0 +1 @@
../../../.claude/skills/review-architecture/SKILL.md
+59
View File
@@ -0,0 +1,59 @@
# Changelog
## 0.6.0 (2026-04-21)
### Features
- **Multi-surface material system** — per-surface materials for walls, stairs, roofs with click-targeted 3D editing ([#266](https://github.com/pascalorg/editor/pull/266)) by [@sudhir9297](https://github.com/sudhir9297)
- **Automatic wall-room generation** — closed wall loops auto-split and generate slabs ([#255](https://github.com/pascalorg/editor/pull/255), [#257](https://github.com/pascalorg/editor/pull/257)) by [@sudhir9297](https://github.com/sudhir9297)
- **Stair-slab integration** — stair-driven cutouts in slabs and ceilings, auto ceilings from wall loops
- **Curved fence support** + endpoint move tools ([#267](https://github.com/pascalorg/editor/pull/267)) by [@sudhir9297](https://github.com/sudhir9297)
- **13 material presets** — granite, marble, parquet, wallpaper, wood and more ([#231](https://github.com/pascalorg/editor/pull/231)) by [@sudhir9297](https://github.com/sudhir9297)
- **Export scene system** — GLB, STL, OBJ formats ([#203](https://github.com/pascalorg/editor/pull/203)) by [@zephran-dev](https://github.com/zephran-dev), with STL/OBJ groundwork by [@mvanhorn](https://github.com/mvanhorn) ([#175](https://github.com/pascalorg/editor/pull/175))
- **Street view / walkthrough mode** ([#173](https://github.com/pascalorg/editor/pull/173)) by [@Yashism](https://github.com/Yashism)
- **Duplicate project** ([#178](https://github.com/pascalorg/editor/pull/178)) by [@kleenkanteen](https://github.com/kleenkanteen)
- **Editable wall length slider** ([#195](https://github.com/pascalorg/editor/pull/195)) by [@zephran-dev](https://github.com/zephran-dev)
- **Infinity dragging slider** using PointerLock API ([#206](https://github.com/pascalorg/editor/pull/206)) by [@claygeo](https://github.com/claygeo)
- **Material system enhancements** ([#201](https://github.com/pascalorg/editor/pull/201)) by [@PMAT77](https://github.com/PMAT77)
- **Editor layout redesign v2** + 3D box select
- **Move/rotate building** + relative positioning for all tools
- **Grid snap toolbar controls**
- **Cut-out button** in floating action menu for slabs and ceilings
### Fixes
- **WebGPU renderer** — await `renderer.init()` in Canvas GL factory ([#233](https://github.com/pascalorg/editor/pull/233)) by [@b9llach](https://github.com/b9llach)
- **WebGPU fallback** — skip post-processing when unavailable ([#234](https://github.com/pascalorg/editor/pull/234)) by [@b9llach](https://github.com/b9llach)
- **Crash on mode switch** — fix crash when switching to Furniture mode ([#237](https://github.com/pascalorg/editor/pull/237)) by [@txhno](https://github.com/txhno)
- **Crash on duplicate** — prevent crash when duplicating elements ([#239](https://github.com/pascalorg/editor/pull/239)) by [@nnhhoang](https://github.com/nnhhoang)
- **Delete walls/slabs** via floating action menu ([#180](https://github.com/pascalorg/editor/pull/180)) by [@nnhhoang](https://github.com/nnhhoang)
- **Counter-clockwise rotation** — T key for CCW rotation on selected nodes ([#184](https://github.com/pascalorg/editor/pull/184)) by [@nnhhoang](https://github.com/nnhhoang)
- **Scene singleton cleanup** — release singletons on Editor unmount ([#214](https://github.com/pascalorg/editor/pull/214)) by [@geopenta](https://github.com/geopenta)
- **State management & memory leaks** ([#152](https://github.com/pascalorg/editor/pull/152)) by [@hobostay](https://github.com/hobostay)
- **Ghost wall prevention** — use WALL_MIN_LENGTH constant ([#168](https://github.com/pascalorg/editor/pull/168)) by [@zephran-dev](https://github.com/zephran-dev)
- **Catalog image optimization** — add sizes and loading props ([#189](https://github.com/pascalorg/editor/pull/189)) by [@korvixhq](https://github.com/korvixhq)
- **Code cleanup** — remove unused `@ts-expect-error` directive ([#150](https://github.com/pascalorg/editor/pull/150)) by [@cs68614-hash](https://github.com/cs68614-hash)
- Robust undo/redo with nested history pause/resume
- Post-processing recovery after duplicate scene mutations
- Improved snapping across all geometry types
- Thumbnails, placement, and responsiveness improvements
- Stair elevation sync with floor slabs
### Contributors
A huge thank you to everyone who contributed to this release! 🎉
- [@sudhir9297](https://github.com/sudhir9297) — material system, wall-room generation, curved walls, stairs, fences (7 PRs!)
- [@zephran-dev](https://github.com/zephran-dev) — export system, wall length slider, ghost wall fix
- [@nnhhoang](https://github.com/nnhhoang) — rotation controls, delete actions, crash fix
- [@b9llach](https://github.com/b9llach) — WebGPU renderer fixes
- [@txhno](https://github.com/txhno) — furniture mode crash fix
- [@Yashism](https://github.com/Yashism) — street view / walkthrough mode
- [@claygeo](https://github.com/claygeo) — infinity dragging slider
- [@geopenta](https://github.com/geopenta) — scene singleton cleanup
- [@kleenkanteen](https://github.com/kleenkanteen) — duplicate project feature
- [@mvanhorn](https://github.com/mvanhorn) — STL/OBJ export formats
- [@PMAT77](https://github.com/PMAT77) — material system enhancements
- [@korvixhq](https://github.com/korvixhq) — catalog image optimization
- [@hobostay](https://github.com/hobostay) — state management & memory leak fixes
- [@cs68614-hash](https://github.com/cs68614-hash) — code cleanup
Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

+8 -8
View File
@@ -50,7 +50,7 @@
}, },
"packages/core": { "packages/core": {
"name": "@pascal-app/core", "name": "@pascal-app/core",
"version": "0.5.1", "version": "0.6.0",
"dependencies": { "dependencies": {
"dedent": "^1.7.1", "dedent": "^1.7.1",
"idb-keyval": "^6.2.2", "idb-keyval": "^6.2.2",
@@ -77,7 +77,7 @@
}, },
"packages/editor": { "packages/editor": {
"name": "@pascal-app/editor", "name": "@pascal-app/editor",
"version": "0.5.1", "version": "0.6.0",
"dependencies": { "dependencies": {
"@iconify/react": "^6.0.2", "@iconify/react": "^6.0.2",
"@number-flow/react": "^0.5.14", "@number-flow/react": "^0.5.14",
@@ -108,8 +108,8 @@
"zustand": "^5.0.11", "zustand": "^5.0.11",
}, },
"devDependencies": { "devDependencies": {
"@pascal-app/core": "^0.5.1", "@pascal-app/core": "^0.6.0",
"@pascal-app/viewer": "^0.5.1", "@pascal-app/viewer": "^0.6.0",
"@pascal/typescript-config": "*", "@pascal/typescript-config": "*",
"@types/howler": "^2.2.12", "@types/howler": "^2.2.12",
"@types/node": "^22.19.12", "@types/node": "^22.19.12",
@@ -119,8 +119,8 @@
"typescript": "5.9.3", "typescript": "5.9.3",
}, },
"peerDependencies": { "peerDependencies": {
"@pascal-app/core": "^0.5.1", "@pascal-app/core": "^0.6.0",
"@pascal-app/viewer": "^0.5.1", "@pascal-app/viewer": "^0.6.0",
"@react-three/drei": "^10", "@react-three/drei": "^10",
"@react-three/fiber": "^9", "@react-three/fiber": "^9",
"next": ">=15", "next": ">=15",
@@ -189,7 +189,7 @@
}, },
"packages/viewer": { "packages/viewer": {
"name": "@pascal-app/viewer", "name": "@pascal-app/viewer",
"version": "0.5.1", "version": "0.6.0",
"dependencies": { "dependencies": {
"polygon-clipping": "^0.15.7", "polygon-clipping": "^0.15.7",
"zustand": "^5", "zustand": "^5",
@@ -202,7 +202,7 @@
"typescript": "5.9.3", "typescript": "5.9.3",
}, },
"peerDependencies": { "peerDependencies": {
"@pascal-app/core": "^0.5.1", "@pascal-app/core": "^0.6.0",
"@react-three/drei": "^10", "@react-three/drei": "^10",
"@react-three/fiber": "^9", "@react-three/fiber": "^9",
"react": "^18 || ^19", "react": "^18 || ^19",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@pascal-app/core", "name": "@pascal-app/core",
"version": "0.5.1", "version": "0.6.0",
"description": "Core library for Pascal 3D building editor", "description": "Core library for Pascal 3D building editor",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
+3
View File
@@ -1,4 +1,5 @@
import type { ThreeEvent } from '@react-three/fiber' import type { ThreeEvent } from '@react-three/fiber'
import type { Object3D } from 'three'
import mitt from 'mitt' import mitt from 'mitt'
import type { import type {
BuildingNode, BuildingNode,
@@ -38,6 +39,8 @@ export interface NodeEvent<T extends AnyNode = AnyNode> {
position: [number, number, number] position: [number, number, number]
localPosition: [number, number, number] localPosition: [number, number, number]
normal?: [number, number, number] normal?: [number, number, number]
faceIndex?: number
object: Object3D
stopPropagation: () => void stopPropagation: () => void
nativeEvent: ThreeEvent<PointerEvent> nativeEvent: ThreeEvent<PointerEvent>
} }
+9 -1
View File
@@ -42,9 +42,11 @@ export {
getCatalogMaterialById, getCatalogMaterialById,
getLibraryMaterialIdFromRef, getLibraryMaterialIdFromRef,
getMaterialPresetByRef, getMaterialPresetByRef,
getMaterialsForTarget, getMaterialsForCategory,
LIBRARY_MATERIAL_REF_PREFIX, LIBRARY_MATERIAL_REF_PREFIX,
MATERIAL_CATALOG, MATERIAL_CATALOG,
MATERIAL_CATEGORIES,
type MaterialCategory,
type MaterialCatalogItem, type MaterialCatalogItem,
toLibraryMaterialRef, toLibraryMaterialRef,
} from './material-library' } from './material-library'
@@ -55,6 +57,12 @@ export {
type ItemInteractiveState, type ItemInteractiveState,
useInteractive, useInteractive,
} from './store/use-interactive' } from './store/use-interactive'
export {
getSceneHistoryPauseDepth,
pauseSceneHistory,
resetSceneHistoryPauseDepth,
resumeSceneHistory,
} from './store/history-control'
export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms' export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms'
export { clearSceneHistory, default as useScene } from './store/use-scene' export { clearSceneHistory, default as useScene } from './store/use-scene'
export { CeilingSystem } from './systems/ceiling/ceiling-system' export { CeilingSystem } from './systems/ceiling/ceiling-system'
+8 -2
View File
@@ -4,6 +4,11 @@ import {
isCurvedWall, isCurvedWall,
} from '../systems/wall/wall-curve' } from '../systems/wall/wall-curve'
import { CeilingNode, SlabNode, type CeilingNode as CeilingNodeType, type SlabNode as SlabNodeType, type WallNode } from '../schema' import { CeilingNode, SlabNode, type CeilingNode as CeilingNodeType, type SlabNode as SlabNodeType, type WallNode } from '../schema'
import {
getSceneHistoryPauseDepth,
pauseSceneHistory,
resumeSceneHistory,
} from '../store/history-control'
import { simplifyClosedPolygon } from './polygon-geometry' import { simplifyClosedPolygon } from './polygon-geometry'
type Point2D = { x: number; y: number } type Point2D = { x: number; y: number }
@@ -855,6 +860,7 @@ export function initSpaceDetectionSync(sceneStore: any, editorStore: any): () =>
const unsubscribe = sceneStore.subscribe((state: any) => { const unsubscribe = sceneStore.subscribe((state: any) => {
if (isProcessing) return if (isProcessing) return
if (getSceneHistoryPauseDepth() > 0) return
const nodes = state.nodes const nodes = state.nodes
const wallsByLevel = new Map<string, WallNode[]>() const wallsByLevel = new Map<string, WallNode[]>()
@@ -889,11 +895,11 @@ export function initSpaceDetectionSync(sceneStore: any, editorStore: any): () =>
} }
isProcessing = true isProcessing = true
sceneStore.temporal.getState().pause() pauseSceneHistory(sceneStore)
try { try {
runSpaceDetection([...levelsToUpdate], sceneStore, editorStore, nodes) runSpaceDetection([...levelsToUpdate], sceneStore, editorStore, nodes)
} finally { } finally {
sceneStore.temporal.getState().resume() resumeSceneHistory(sceneStore)
previousSnapshots.clear() previousSnapshots.clear()
for (const [levelId, snapshot] of currentSnapshots.entries()) { for (const [levelId, snapshot] of currentSnapshots.entries()) {
previousSnapshots.set(levelId, snapshot) previousSnapshots.set(levelId, snapshot)
+28 -58
View File
@@ -1,57 +1,33 @@
import { import {
type MaterialPresetPayload, type MaterialPresetPayload,
type MaterialTarget,
MaterialTarget as MaterialTargetSchema,
} from './schema/material' } from './schema/material'
export type MaterialCatalogItem = { export type MaterialCatalogItem = {
id: string id: string
label: string label: string
category: MaterialCategory
description?: string description?: string
targets: MaterialTarget[]
previewThumbnailUrl?: string previewThumbnailUrl?: string
previewColor?: string previewColor?: string
preset: MaterialPresetPayload preset: MaterialPresetPayload
} }
const WALL_TARGETS: MaterialTarget[] = [ export const MATERIAL_CATEGORIES = [
MaterialTargetSchema.enum.wall, 'wood',
] 'wallpaper',
'parquet',
const SLAB_TARGETS: MaterialTarget[] = [ 'granite',
MaterialTargetSchema.enum.slab, 'marble',
] 'other',
] as const
const WALL_AND_SLAB_TARGETS: MaterialTarget[] = [ export type MaterialCategory = (typeof MATERIAL_CATEGORIES)[number]
MaterialTargetSchema.enum.wall,
MaterialTargetSchema.enum.slab,
]
const STAIR_TARGETS: MaterialTarget[] = [
MaterialTargetSchema.enum.stair,
MaterialTargetSchema.enum['stair-segment'],
]
const STAIR_AND_FENCE_TARGETS: MaterialTarget[] = [
...STAIR_TARGETS,
MaterialTargetSchema.enum.fence,
]
const ROOF_TARGETS: MaterialTarget[] = [
MaterialTargetSchema.enum.roof,
MaterialTargetSchema.enum['roof-segment'],
]
const CEILING_TARGETS: MaterialTarget[] = [
MaterialTargetSchema.enum.ceiling,
]
export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{ {
id: 'wall-wood1', id: 'wall-wood1',
label: 'Wood', label: 'Wood',
category: 'wood',
description: 'Warm wood finish', description: 'Warm wood finish',
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
previewThumbnailUrl: '/material/wood1/wood1_thumbnail.webp', previewThumbnailUrl: '/material/wood1/wood1_thumbnail.webp',
preset: { preset: {
maps: { maps: {
@@ -85,8 +61,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{ {
id: 'wall-wood2', id: 'wall-wood2',
label: 'Wood', label: 'Wood',
category: 'wood',
description: 'Textured wood finish', description: 'Textured wood finish',
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
previewThumbnailUrl: '/material/wood2/wood2_thumbnail.webp', previewThumbnailUrl: '/material/wood2/wood2_thumbnail.webp',
preset: { preset: {
maps: { maps: {
@@ -121,8 +97,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{ {
id: 'wall-wood3', id: 'wall-wood3',
label: 'Wood', label: 'Wood',
category: 'wood',
description: 'Knotted timber finish', description: 'Knotted timber finish',
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
previewThumbnailUrl: '/material/wood3/wood3_thumbnail.webp', previewThumbnailUrl: '/material/wood3/wood3_thumbnail.webp',
preset: { preset: {
maps: { maps: {
@@ -155,8 +131,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{ {
id: 'wall-wood4', id: 'wall-wood4',
label: 'Wood', label: 'Wood',
category: 'wood',
description: 'Oak stretcher finish', description: 'Oak stretcher finish',
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
previewThumbnailUrl: '/material/wood4/wood4_thumbnail.webp', previewThumbnailUrl: '/material/wood4/wood4_thumbnail.webp',
preset: { preset: {
maps: { maps: {
@@ -189,8 +165,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{ {
id: 'wall-wood5', id: 'wall-wood5',
label: 'Wood', label: 'Wood',
category: 'wood',
description: 'Rich grain wood finish', description: 'Rich grain wood finish',
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
previewThumbnailUrl: '/material/wood5/wood5_thumnail.webp', previewThumbnailUrl: '/material/wood5/wood5_thumnail.webp',
preset: { preset: {
maps: { maps: {
@@ -225,8 +201,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{ {
id: 'wall-granite1', id: 'wall-granite1',
label: 'Granite', label: 'Granite',
category: 'granite',
description: 'Polished granite finish', description: 'Polished granite finish',
targets: SLAB_TARGETS,
previewThumbnailUrl: '/material/granite1/granite_thumbnail.webp', previewThumbnailUrl: '/material/granite1/granite_thumbnail.webp',
preset: { preset: {
maps: { maps: {
@@ -259,8 +235,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{ {
id: 'wall-marble1', id: 'wall-marble1',
label: 'Marble', label: 'Marble',
category: 'marble',
description: 'Smooth marble finish', description: 'Smooth marble finish',
targets: [...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
previewThumbnailUrl: '/material/marble1/marble1_thumbnail.webp', previewThumbnailUrl: '/material/marble1/marble1_thumbnail.webp',
preset: { preset: {
maps: { maps: {
@@ -293,8 +269,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{ {
id: 'wall-marble2', id: 'wall-marble2',
label: 'Marble', label: 'Marble',
category: 'marble',
description: 'Soft marble finish', description: 'Soft marble finish',
targets: [...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
previewThumbnailUrl: '/material/marble2/marble2_thumbnail.webp', previewThumbnailUrl: '/material/marble2/marble2_thumbnail.webp',
preset: { preset: {
maps: { maps: {
@@ -327,8 +303,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{ {
id: 'wall-parquet1', id: 'wall-parquet1',
label: 'Parquet', label: 'Parquet',
category: 'parquet',
description: 'Parquet wood finish', description: 'Parquet wood finish',
targets: SLAB_TARGETS,
previewThumbnailUrl: '/material/parquet1/parquet_thumnail.webp', previewThumbnailUrl: '/material/parquet1/parquet_thumnail.webp',
preset: { preset: {
maps: { maps: {
@@ -361,8 +337,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{ {
id: 'wall-parquet2', id: 'wall-parquet2',
label: 'Parquet', label: 'Parquet',
category: 'parquet',
description: 'Soft parquet finish', description: 'Soft parquet finish',
targets: SLAB_TARGETS,
previewThumbnailUrl: '/material/parquet2/parquet2_thumbnail.webp', previewThumbnailUrl: '/material/parquet2/parquet2_thumbnail.webp',
preset: { preset: {
maps: { maps: {
@@ -395,8 +371,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{ {
id: 'wall-wallpaper1', id: 'wall-wallpaper1',
label: 'Wallpaper', label: 'Wallpaper',
category: 'wallpaper',
description: 'Soft wallpaper finish', description: 'Soft wallpaper finish',
targets: WALL_TARGETS,
previewThumbnailUrl: '/material/wallpaper1/wallpaper1_thumbnail.webp', previewThumbnailUrl: '/material/wallpaper1/wallpaper1_thumbnail.webp',
preset: { preset: {
maps: { maps: {
@@ -430,8 +406,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{ {
id: 'wall-wallpaper2', id: 'wall-wallpaper2',
label: 'Wallpaper', label: 'Wallpaper',
category: 'wallpaper',
description: 'Decorative wallpaper finish', description: 'Decorative wallpaper finish',
targets: WALL_TARGETS,
previewThumbnailUrl: '/material/wallpaper2/wallpaper2_thumnail.webp', previewThumbnailUrl: '/material/wallpaper2/wallpaper2_thumnail.webp',
preset: { preset: {
maps: { maps: {
@@ -464,8 +440,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{ {
id: 'wall-wallpaper3', id: 'wall-wallpaper3',
label: 'Wallpaper', label: 'Wallpaper',
category: 'wallpaper',
description: 'Patterned wallpaper finish', description: 'Patterned wallpaper finish',
targets: WALL_TARGETS,
previewThumbnailUrl: '/material/wallpaper3/wallpaper3_thumbnail.webp', previewThumbnailUrl: '/material/wallpaper3/wallpaper3_thumbnail.webp',
preset: { preset: {
maps: { maps: {
@@ -498,14 +474,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{ {
id: 'preset-white', id: 'preset-white',
label: 'White', label: 'White',
category: 'other',
description: 'Clean painted finish', description: 'Clean painted finish',
targets: [
...WALL_TARGETS,
...SLAB_TARGETS,
...ROOF_TARGETS,
...STAIR_AND_FENCE_TARGETS,
...CEILING_TARGETS,
],
previewColor: '#ffffff', previewColor: '#ffffff',
preset: { preset: {
maps: {}, maps: {},
@@ -536,8 +506,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{ {
id: 'preset-metal', id: 'preset-metal',
label: 'Metal', label: 'Metal',
category: 'other',
description: 'Brushed metal finish', description: 'Brushed metal finish',
targets: [...WALL_TARGETS, ...SLAB_TARGETS],
previewColor: '#c0c0c0', previewColor: '#c0c0c0',
preset: { preset: {
maps: {}, maps: {},
@@ -568,8 +538,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{ {
id: 'preset-glass', id: 'preset-glass',
label: 'Glass', label: 'Glass',
category: 'other',
description: 'Light glass finish', description: 'Light glass finish',
targets: [...WALL_TARGETS, ...SLAB_TARGETS],
previewColor: '#87ceeb', previewColor: '#87ceeb',
preset: { preset: {
maps: {}, maps: {},
@@ -599,8 +569,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
}, },
] ]
export function getMaterialsForTarget(target: MaterialTarget): MaterialCatalogItem[] { export function getMaterialsForCategory(category: MaterialCategory): MaterialCatalogItem[] {
return MATERIAL_CATALOG.filter((item) => item.targets.includes(target)) return MATERIAL_CATALOG.filter((item) => item.category === category)
} }
export function getCatalogMaterialById(id?: string): MaterialCatalogItem | undefined { export function getCatalogMaterialById(id?: string): MaterialCatalogItem | undefined {
+10 -2
View File
@@ -43,22 +43,30 @@ export type {
} from './nodes/item' } from './nodes/item'
export { getScaledDimensions, ItemNode } from './nodes/item' export { getScaledDimensions, ItemNode } from './nodes/item'
export { LevelNode } from './nodes/level' export { LevelNode } from './nodes/level'
export { RoofNode } from './nodes/roof' export { getEffectiveRoofSurfaceMaterial, RoofNode } from './nodes/roof'
export type { RoofSurfaceMaterialRole, RoofSurfaceMaterialSpec } from './nodes/roof'
export { RoofSegmentNode, RoofType } from './nodes/roof-segment' export { RoofSegmentNode, RoofType } from './nodes/roof-segment'
export { ScanNode } from './nodes/scan' export { ScanNode } from './nodes/scan'
// Nodes // Nodes
export { SiteNode } from './nodes/site' export { SiteNode } from './nodes/site'
export { SlabNode } from './nodes/slab' export { SlabNode } from './nodes/slab'
export { export {
getEffectiveStairSurfaceMaterial,
StairNode, StairNode,
StairRailingMode, StairRailingMode,
StairSlabOpeningMode, StairSlabOpeningMode,
StairTopLandingMode, StairTopLandingMode,
StairType, StairType,
} from './nodes/stair' } from './nodes/stair'
export type { StairSurfaceMaterialRole, StairSurfaceMaterialSpec } from './nodes/stair'
export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment' export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment'
export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata' export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata'
export { WallNode } from './nodes/wall' export type { WallSurfaceMaterialSpec, WallSurfaceSide } from './nodes/wall'
export {
getEffectiveWallSurfaceMaterial,
getWallSurfaceMaterialSignature,
WallNode,
} from './nodes/wall'
export { WindowNode } from './nodes/window' export { WindowNode } from './nodes/window'
export { ZoneNode } from './nodes/zone' export { ZoneNode } from './nodes/zone'
export type { AnyNodeId, AnyNodeType } from './types' export type { AnyNodeId, AnyNodeType } from './types'
+2
View File
@@ -13,6 +13,7 @@ export const FenceNode = BaseNode.extend({
materialPreset: z.string().optional(), materialPreset: z.string().optional(),
start: z.tuple([z.number(), z.number()]), start: z.tuple([z.number(), z.number()]),
end: z.tuple([z.number(), z.number()]), end: z.tuple([z.number(), z.number()]),
curveOffset: z.number().optional(),
height: z.number().default(1.8), height: z.number().default(1.8),
thickness: z.number().default(0.08), thickness: z.number().default(0.08),
baseHeight: z.number().default(0.22), baseHeight: z.number().default(0.22),
@@ -28,6 +29,7 @@ export const FenceNode = BaseNode.extend({
dedent` dedent`
Fence node - used to represent a fence segment in the building/site level coordinate system Fence node - used to represent a fence segment in the building/site level coordinate system
- start/end: fence endpoints in level coordinate system - start/end: fence endpoints in level coordinate system
- curveOffset: midpoint sagitta offset used to bend the fence into an arc
- height/thickness: overall fence dimensions in meters - height/thickness: overall fence dimensions in meters
- baseHeight/postSpacing/postSize/topRailHeight: exact geometric controls from the plan3D fence model - baseHeight/postSpacing/postSize/topRailHeight: exact geometric controls from the plan3D fence model
- groundClearance/edgeInset/baseStyle: fence support and inset configuration - groundClearance/edgeInset/baseStyle: fence support and inset configuration
+77 -2
View File
@@ -1,14 +1,26 @@
import dedent from 'dedent' import dedent from 'dedent'
import { z } from 'zod' import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base' import { BaseNode, nodeType, objectId } from '../base'
import { MaterialSchema } from '../material' import { type MaterialSchema, MaterialSchema as MaterialSchemaSchema } from '../material'
import { RoofSegmentNode } from './roof-segment' import { RoofSegmentNode } from './roof-segment'
export type RoofSurfaceMaterialRole = 'top' | 'edge' | 'wall'
export type RoofSurfaceMaterialSpec = {
material?: MaterialSchema
materialPreset?: string
}
export const RoofNode = BaseNode.extend({ export const RoofNode = BaseNode.extend({
id: objectId('roof'), id: objectId('roof'),
type: nodeType('roof'), type: nodeType('roof'),
material: MaterialSchema.optional(), material: MaterialSchemaSchema.optional(),
materialPreset: z.string().optional(), materialPreset: z.string().optional(),
topMaterial: MaterialSchemaSchema.optional(),
topMaterialPreset: z.string().optional(),
edgeMaterial: MaterialSchemaSchema.optional(),
edgeMaterialPreset: z.string().optional(),
wallMaterial: MaterialSchemaSchema.optional(),
wallMaterialPreset: z.string().optional(),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Rotation around Y axis in radians // Rotation around Y axis in radians
rotation: z.number().default(0), rotation: z.number().default(0),
@@ -26,3 +38,66 @@ export const RoofNode = BaseNode.extend({
) )
export type RoofNode = z.infer<typeof RoofNode> export type RoofNode = z.infer<typeof RoofNode>
function getLegacyRoofSurfaceMaterial(node: RoofNode): RoofSurfaceMaterialSpec {
return {
material: node.material,
materialPreset: node.materialPreset,
}
}
export function getEffectiveRoofSurfaceMaterial(
node: RoofNode,
role: RoofSurfaceMaterialRole,
): RoofSurfaceMaterialSpec {
if (role === 'top') {
if (node.topMaterial !== undefined || typeof node.topMaterialPreset === 'string') {
return {
material: node.topMaterial,
materialPreset: typeof node.topMaterialPreset === 'string' ? node.topMaterialPreset : undefined,
}
}
}
if (role === 'edge') {
if (node.edgeMaterial !== undefined || typeof node.edgeMaterialPreset === 'string') {
return {
material: node.edgeMaterial,
materialPreset:
typeof node.edgeMaterialPreset === 'string' ? node.edgeMaterialPreset : undefined,
}
}
}
if (role === 'wall') {
if (node.wallMaterial !== undefined || typeof node.wallMaterialPreset === 'string') {
return {
material: node.wallMaterial,
materialPreset:
typeof node.wallMaterialPreset === 'string' ? node.wallMaterialPreset : undefined,
}
}
}
if (role === 'edge') {
if (node.wallMaterial !== undefined || typeof node.wallMaterialPreset === 'string') {
return {
material: node.wallMaterial,
materialPreset:
typeof node.wallMaterialPreset === 'string' ? node.wallMaterialPreset : undefined,
}
}
}
if (role === 'wall') {
if (node.edgeMaterial !== undefined || typeof node.edgeMaterialPreset === 'string') {
return {
material: node.edgeMaterial,
materialPreset:
typeof node.edgeMaterialPreset === 'string' ? node.edgeMaterialPreset : undefined,
}
}
}
return getLegacyRoofSurfaceMaterial(node)
}
+84 -2
View File
@@ -1,7 +1,7 @@
import dedent from 'dedent' import dedent from 'dedent'
import { z } from 'zod' import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base' import { BaseNode, nodeType, objectId } from '../base'
import { MaterialSchema } from '../material' import { type MaterialSchema, MaterialSchema as MaterialSchemaSchema } from '../material'
import { StairSegmentNode } from './stair-segment' import { StairSegmentNode } from './stair-segment'
export const StairRailingMode = z.enum(['none', 'left', 'right', 'both']) export const StairRailingMode = z.enum(['none', 'left', 'right', 'both'])
@@ -13,12 +13,23 @@ export type StairRailingMode = z.infer<typeof StairRailingMode>
export type StairType = z.infer<typeof StairType> export type StairType = z.infer<typeof StairType>
export type StairTopLandingMode = z.infer<typeof StairTopLandingMode> export type StairTopLandingMode = z.infer<typeof StairTopLandingMode>
export type StairSlabOpeningMode = z.infer<typeof StairSlabOpeningMode> export type StairSlabOpeningMode = z.infer<typeof StairSlabOpeningMode>
export type StairSurfaceMaterialRole = 'railing' | 'tread' | 'side'
export type StairSurfaceMaterialSpec = {
material?: MaterialSchema
materialPreset?: string
}
export const StairNode = BaseNode.extend({ export const StairNode = BaseNode.extend({
id: objectId('stair'), id: objectId('stair'),
type: nodeType('stair'), type: nodeType('stair'),
material: MaterialSchema.optional(), material: MaterialSchemaSchema.optional(),
materialPreset: z.string().optional(), materialPreset: z.string().optional(),
railingMaterial: MaterialSchemaSchema.optional(),
railingMaterialPreset: z.string().optional(),
treadMaterial: MaterialSchemaSchema.optional(),
treadMaterialPreset: z.string().optional(),
sideMaterial: MaterialSchemaSchema.optional(),
sideMaterialPreset: z.string().optional(),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Rotation around Y axis in radians // Rotation around Y axis in radians
rotation: z.number().default(0), rotation: z.number().default(0),
@@ -71,3 +82,74 @@ export const StairNode = BaseNode.extend({
) )
export type StairNode = z.infer<typeof StairNode> export type StairNode = z.infer<typeof StairNode>
function getLegacyStairSurfaceMaterial(node: StairNode): StairSurfaceMaterialSpec {
return {
material: node.material,
materialPreset: node.materialPreset,
}
}
export function getEffectiveStairSurfaceMaterial(
node: StairNode,
role: StairSurfaceMaterialRole,
): StairSurfaceMaterialSpec {
if (role === 'railing') {
if (node.railingMaterial !== undefined || typeof node.railingMaterialPreset === 'string') {
return {
material: node.railingMaterial,
materialPreset:
typeof node.railingMaterialPreset === 'string' ? node.railingMaterialPreset : undefined,
}
}
}
if (role === 'tread') {
if (node.treadMaterial !== undefined || typeof node.treadMaterialPreset === 'string') {
return {
material: node.treadMaterial,
materialPreset:
typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined,
}
}
}
if (role === 'side') {
if (node.sideMaterial !== undefined || typeof node.sideMaterialPreset === 'string') {
return {
material: node.sideMaterial,
materialPreset:
typeof node.sideMaterialPreset === 'string' ? node.sideMaterialPreset : undefined,
}
}
}
const treadFallback = {
material: node.treadMaterial,
materialPreset: typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined,
}
const sideFallback = {
material: node.sideMaterial,
materialPreset: typeof node.sideMaterialPreset === 'string' ? node.sideMaterialPreset : undefined,
}
if (role === 'tread' && (sideFallback.material !== undefined || sideFallback.materialPreset !== undefined)) {
return sideFallback
}
if (role === 'side' && (treadFallback.material !== undefined || treadFallback.materialPreset !== undefined)) {
return treadFallback
}
if (role === 'railing') {
if (treadFallback.material !== undefined || treadFallback.materialPreset !== undefined) {
return treadFallback
}
if (sideFallback.material !== undefined || sideFallback.materialPreset !== undefined) {
return sideFallback
}
}
return getLegacyStairSurfaceMaterial(node)
}
+65
View File
@@ -12,8 +12,14 @@ export const WallNode = BaseNode.extend({
children: z children: z
.array(z.union([ItemNode.shape.id, DoorNode.shape.id, WindowNode.shape.id])) .array(z.union([ItemNode.shape.id, DoorNode.shape.id, WindowNode.shape.id]))
.default([]), .default([]),
// Legacy single-material wall finish. Read for backward compatibility only.
material: MaterialSchema.optional(), material: MaterialSchema.optional(),
// Legacy single-material wall finish preset. Read for backward compatibility only.
materialPreset: z.string().optional(), materialPreset: z.string().optional(),
interiorMaterial: MaterialSchema.optional(),
interiorMaterialPreset: z.string().optional(),
exteriorMaterial: MaterialSchema.optional(),
exteriorMaterialPreset: z.string().optional(),
thickness: z.number().optional(), thickness: z.number().optional(),
height: z.number().optional(), height: z.number().optional(),
curveOffset: z.number().optional(), curveOffset: z.number().optional(),
@@ -37,3 +43,62 @@ export const WallNode = BaseNode.extend({
`, `,
) )
export type WallNode = z.infer<typeof WallNode> export type WallNode = z.infer<typeof WallNode>
export type WallSurfaceSide = 'interior' | 'exterior'
export type WallSurfaceMaterialSpec = {
material?: z.infer<typeof MaterialSchema>
materialPreset?: string
}
type WallSurfaceMaterialSource = {
material?: z.infer<typeof MaterialSchema>
materialPreset?: string
interiorMaterial?: z.infer<typeof MaterialSchema>
interiorMaterialPreset?: string
exteriorMaterial?: z.infer<typeof MaterialSchema>
exteriorMaterialPreset?: string
}
function getConfiguredWallSurfaceMaterial(
wall: WallSurfaceMaterialSource,
side: WallSurfaceSide,
): WallSurfaceMaterialSpec {
if (side === 'interior') {
return {
material: wall.interiorMaterial,
materialPreset: wall.interiorMaterialPreset,
}
}
return {
material: wall.exteriorMaterial,
materialPreset: wall.exteriorMaterialPreset,
}
}
function hasSurfaceMaterial(spec: WallSurfaceMaterialSpec): boolean {
return spec.material !== undefined || typeof spec.materialPreset === 'string'
}
export function getEffectiveWallSurfaceMaterial(
wall: WallSurfaceMaterialSource,
side: WallSurfaceSide,
): WallSurfaceMaterialSpec {
const configured = getConfiguredWallSurfaceMaterial(wall, side)
if (hasSurfaceMaterial(configured)) {
return configured
}
return {
material: wall.material,
materialPreset: wall.materialPreset,
}
}
export function getWallSurfaceMaterialSignature(spec: WallSurfaceMaterialSpec): string {
return JSON.stringify({
material: spec.material ?? null,
materialPreset: spec.materialPreset ?? null,
})
}
+29 -21
View File
@@ -1,4 +1,10 @@
import type { AnyNode, AnyNodeId, WallNode } from '../../schema' import {
type AnyNode,
type AnyNodeId,
getEffectiveWallSurfaceMaterial,
getWallSurfaceMaterialSignature,
type WallNode,
} from '../../schema'
import type { CollectionId } from '../../schema/collections' import type { CollectionId } from '../../schema/collections'
import type { SceneState } from '../use-scene' import type { SceneState } from '../use-scene'
@@ -17,11 +23,7 @@ type WallMergePlan = {
let pendingRafId: number | null = null let pendingRafId: number | null = null
let pendingUpdates: Set<AnyNodeId> = new Set() let pendingUpdates: Set<AnyNodeId> = new Set()
function pointsEqual( function pointsEqual(a: [number, number], b: [number, number], tolerance = 1e-6) {
a: [number, number],
b: [number, number],
tolerance = 1e-6,
) {
const dx = a[0] - b[0] const dx = a[0] - b[0]
const dz = a[1] - b[1] const dz = a[1] - b[1]
return dx * dx + dz * dz <= tolerance * tolerance return dx * dx + dz * dz <= tolerance * tolerance
@@ -40,32 +42,30 @@ function getWallEndpointAtPoint(
return null return null
} }
function getWallFreeEndpoint( function getWallFreeEndpoint(wall: Pick<WallNode, 'start' | 'end'>, sharedPoint: [number, number]) {
wall: Pick<WallNode, 'start' | 'end'>,
sharedPoint: [number, number],
) {
return pointsEqual(wall.start, sharedPoint) ? wall.end : wall.start return pointsEqual(wall.start, sharedPoint) ? wall.end : wall.start
} }
function areWallStylesCompatible(a: WallNode, b: WallNode) { function areWallStylesCompatible(a: WallNode, b: WallNode) {
const aInterior = getWallSurfaceMaterialSignature(getEffectiveWallSurfaceMaterial(a, 'interior'))
const bInterior = getWallSurfaceMaterialSignature(getEffectiveWallSurfaceMaterial(b, 'interior'))
const aExterior = getWallSurfaceMaterialSignature(getEffectiveWallSurfaceMaterial(a, 'exterior'))
const bExterior = getWallSurfaceMaterialSignature(getEffectiveWallSurfaceMaterial(b, 'exterior'))
return ( return (
(a.parentId ?? null) === (b.parentId ?? null) && (a.parentId ?? null) === (b.parentId ?? null) &&
Math.abs((a.curveOffset ?? 0) - (b.curveOffset ?? 0)) <= 1e-6 && Math.abs((a.curveOffset ?? 0) - (b.curveOffset ?? 0)) <= 1e-6 &&
Math.abs((a.thickness ?? 0.2) - (b.thickness ?? 0.2)) <= 1e-6 && Math.abs((a.thickness ?? 0.2) - (b.thickness ?? 0.2)) <= 1e-6 &&
Math.abs((a.height ?? 2.5) - (b.height ?? 2.5)) <= 1e-6 && Math.abs((a.height ?? 2.5) - (b.height ?? 2.5)) <= 1e-6 &&
a.materialPreset === b.materialPreset && aInterior === bInterior &&
JSON.stringify(a.material ?? null) === JSON.stringify(b.material ?? null) && aExterior === bExterior &&
a.frontSide === b.frontSide && a.frontSide === b.frontSide &&
a.backSide === b.backSide && a.backSide === b.backSide &&
a.visible === b.visible a.visible === b.visible
) )
} }
function areWallsCollinearAcrossPoint( function areWallsCollinearAcrossPoint(a: WallNode, b: WallNode, sharedPoint: [number, number]) {
a: WallNode,
b: WallNode,
sharedPoint: [number, number],
) {
const freeA = getWallFreeEndpoint(a, sharedPoint) const freeA = getWallFreeEndpoint(a, sharedPoint)
const freeB = getWallFreeEndpoint(b, sharedPoint) const freeB = getWallFreeEndpoint(b, sharedPoint)
const ax = freeA[0] - sharedPoint[0] const ax = freeA[0] - sharedPoint[0]
@@ -111,7 +111,10 @@ function buildMergedWallAttachmentUpdates(
mergedEnd: [number, number], mergedEnd: [number, number],
nodes: Record<AnyNodeId, AnyNode>, nodes: Record<AnyNodeId, AnyNode>,
): WallAttachmentUpdate[] { ): WallAttachmentUpdate[] {
const mergedLength = Math.max(Math.hypot(mergedEnd[0] - mergedStart[0], mergedEnd[1] - mergedStart[1]), 1e-6) const mergedLength = Math.max(
Math.hypot(mergedEnd[0] - mergedStart[0], mergedEnd[1] - mergedStart[1]),
1e-6,
)
const tangentX = (mergedEnd[0] - mergedStart[0]) / mergedLength const tangentX = (mergedEnd[0] - mergedStart[0]) / mergedLength
const tangentZ = (mergedEnd[1] - mergedStart[1]) / mergedLength const tangentZ = (mergedEnd[1] - mergedStart[1]) / mergedLength
const updates: WallAttachmentUpdate[] = [] const updates: WallAttachmentUpdate[] = []
@@ -126,11 +129,16 @@ function buildMergedWallAttachmentUpdates(
const sourceWall = child.parentId === secondary.id ? secondary : primary const sourceWall = child.parentId === secondary.id ? secondary : primary
const sourceLength = Math.max(wallLength(sourceWall), 1e-6) const sourceLength = Math.max(wallLength(sourceWall), 1e-6)
const localX = typeof child.position[0] === 'number' ? child.position[0] : 0 const localX = typeof child.position[0] === 'number' ? child.position[0] : 0
const worldX = sourceWall.start[0] + ((sourceWall.end[0] - sourceWall.start[0]) * localX) / sourceLength const worldX =
const worldZ = sourceWall.start[1] + ((sourceWall.end[1] - sourceWall.start[1]) * localX) / sourceLength sourceWall.start[0] + ((sourceWall.end[0] - sourceWall.start[0]) * localX) / sourceLength
const worldZ =
sourceWall.start[1] + ((sourceWall.end[1] - sourceWall.start[1]) * localX) / sourceLength
const nextLocalX = Math.max( const nextLocalX = Math.max(
0, 0,
Math.min(mergedLength, (worldX - mergedStart[0]) * tangentX + (worldZ - mergedStart[1]) * tangentZ), Math.min(
mergedLength,
(worldX - mergedStart[0]) * tangentX + (worldZ - mergedStart[1]) * tangentZ,
),
) )
updates.push({ updates.push({
@@ -0,0 +1,36 @@
let sceneHistoryPauseDepth = 0
type TemporalStoreLike = {
temporal: {
getState(): {
pause(): void
resume(): void
}
}
}
export function pauseSceneHistory(sceneStore: TemporalStoreLike): void {
if (sceneHistoryPauseDepth === 0) {
sceneStore.temporal.getState().pause()
}
sceneHistoryPauseDepth += 1
}
export function resumeSceneHistory(sceneStore: TemporalStoreLike): void {
if (sceneHistoryPauseDepth === 0) {
return
}
sceneHistoryPauseDepth -= 1
if (sceneHistoryPauseDepth === 0) {
sceneStore.temporal.getState().resume()
}
}
export function getSceneHistoryPauseDepth(): number {
return sceneHistoryPauseDepth
}
export function resetSceneHistoryPauseDepth(): void {
sceneHistoryPauseDepth = 0
}
+197 -3
View File
@@ -11,6 +11,7 @@ import { SiteNode } from '../schema/nodes/site'
import { StairNode as StairNodeSchema } from '../schema/nodes/stair' import { StairNode as StairNodeSchema } from '../schema/nodes/stair'
import { StairSegmentNode as StairSegmentNodeSchema } from '../schema/nodes/stair-segment' import { StairSegmentNode as StairSegmentNodeSchema } from '../schema/nodes/stair-segment'
import type { AnyNode, AnyNodeId } from '../schema/types' import type { AnyNode, AnyNodeId } from '../schema/types'
import { resetSceneHistoryPauseDepth } from './history-control'
import * as nodeActions from './actions/node-actions' import * as nodeActions from './actions/node-actions'
function getFiniteNumber(value: unknown, fallback: number) { function getFiniteNumber(value: unknown, fallback: number) {
@@ -100,6 +101,189 @@ function normalizeStairSegmentNode(node: Record<string, unknown>) {
return parsed.success ? parsed.data : null return parsed.success ? parsed.data : null
} }
function migrateWallSurfaceMaterials(node: Record<string, any>) {
const hasInterior =
node.interiorMaterial !== undefined || typeof node.interiorMaterialPreset === 'string'
const hasExterior =
node.exteriorMaterial !== undefined || typeof node.exteriorMaterialPreset === 'string'
const legacyFinish = {
material: node.material,
materialPreset: typeof node.materialPreset === 'string' ? node.materialPreset : undefined,
}
if (!hasInterior && !hasExterior) {
if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) {
return node
}
return {
...node,
interiorMaterial: legacyFinish.material,
interiorMaterialPreset: legacyFinish.materialPreset,
exteriorMaterial: legacyFinish.material,
exteriorMaterialPreset: legacyFinish.materialPreset,
}
}
if (!hasInterior) {
return {
...node,
interiorMaterial: node.exteriorMaterial,
interiorMaterialPreset: node.exteriorMaterialPreset,
}
}
if (!hasExterior) {
return {
...node,
exteriorMaterial: node.interiorMaterial,
exteriorMaterialPreset: node.interiorMaterialPreset,
}
}
return node
}
function migrateStairSurfaceMaterials(node: Record<string, any>) {
const hasRailing =
node.railingMaterial !== undefined || typeof node.railingMaterialPreset === 'string'
const hasTread = node.treadMaterial !== undefined || typeof node.treadMaterialPreset === 'string'
const hasSide = node.sideMaterial !== undefined || typeof node.sideMaterialPreset === 'string'
const legacyFinish = {
material: node.material,
materialPreset: typeof node.materialPreset === 'string' ? node.materialPreset : undefined,
}
const resolveBodyFallback = () => {
if (node.treadMaterial !== undefined || typeof node.treadMaterialPreset === 'string') {
return {
material: node.treadMaterial,
materialPreset:
typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined,
}
}
if (node.sideMaterial !== undefined || typeof node.sideMaterialPreset === 'string') {
return {
material: node.sideMaterial,
materialPreset:
typeof node.sideMaterialPreset === 'string' ? node.sideMaterialPreset : undefined,
}
}
return legacyFinish
}
if (!hasRailing && !hasTread && !hasSide) {
if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) {
return node
}
return {
...node,
railingMaterial: legacyFinish.material,
railingMaterialPreset: legacyFinish.materialPreset,
treadMaterial: legacyFinish.material,
treadMaterialPreset: legacyFinish.materialPreset,
sideMaterial: legacyFinish.material,
sideMaterialPreset: legacyFinish.materialPreset,
}
}
const next = { ...node }
if (!hasTread) {
const fallback =
node.sideMaterial !== undefined || typeof node.sideMaterialPreset === 'string'
? {
material: node.sideMaterial,
materialPreset:
typeof node.sideMaterialPreset === 'string' ? node.sideMaterialPreset : undefined,
}
: resolveBodyFallback()
next.treadMaterial = fallback.material
next.treadMaterialPreset = fallback.materialPreset
}
if (!hasSide) {
const fallback =
node.treadMaterial !== undefined || typeof node.treadMaterialPreset === 'string'
? {
material: node.treadMaterial,
materialPreset:
typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined,
}
: resolveBodyFallback()
next.sideMaterial = fallback.material
next.sideMaterialPreset = fallback.materialPreset
}
if (!hasRailing) {
const fallback = resolveBodyFallback()
next.railingMaterial = fallback.material
next.railingMaterialPreset = fallback.materialPreset
}
return next
}
function migrateRoofSurfaceMaterials(node: Record<string, any>) {
const hasTop = node.topMaterial !== undefined || typeof node.topMaterialPreset === 'string'
const hasEdge = node.edgeMaterial !== undefined || typeof node.edgeMaterialPreset === 'string'
const hasWall = node.wallMaterial !== undefined || typeof node.wallMaterialPreset === 'string'
const legacyFinish = {
material: node.material,
materialPreset: typeof node.materialPreset === 'string' ? node.materialPreset : undefined,
}
if (!hasTop && !hasEdge && !hasWall) {
if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) {
return node
}
return {
...node,
topMaterial: legacyFinish.material,
topMaterialPreset: legacyFinish.materialPreset,
edgeMaterial: legacyFinish.material,
edgeMaterialPreset: legacyFinish.materialPreset,
wallMaterial: legacyFinish.material,
wallMaterialPreset: legacyFinish.materialPreset,
}
}
const next = { ...node }
if (!hasTop) {
next.topMaterial = legacyFinish.material
next.topMaterialPreset = legacyFinish.materialPreset
}
if (!hasEdge) {
if (node.wallMaterial !== undefined || typeof node.wallMaterialPreset === 'string') {
next.edgeMaterial = node.wallMaterial
next.edgeMaterialPreset =
typeof node.wallMaterialPreset === 'string' ? node.wallMaterialPreset : undefined
} else {
next.edgeMaterial = legacyFinish.material
next.edgeMaterialPreset = legacyFinish.materialPreset
}
}
if (!hasWall) {
if (node.edgeMaterial !== undefined || typeof node.edgeMaterialPreset === 'string') {
next.wallMaterial = node.edgeMaterial
next.wallMaterialPreset =
typeof node.edgeMaterialPreset === 'string' ? node.edgeMaterialPreset : undefined
} else {
next.wallMaterial = legacyFinish.material
next.wallMaterialPreset = legacyFinish.materialPreset
}
}
return next
}
function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> { function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
const patchedNodes = { ...nodes } const patchedNodes = { ...nodes }
for (const [id, node] of Object.entries(patchedNodes)) { for (const [id, node] of Object.entries(patchedNodes)) {
@@ -141,7 +325,7 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
} }
if (node.type === 'stair') { if (node.type === 'stair') {
const normalized = normalizeStairNode(node) const normalized = normalizeStairNode(migrateStairSurfaceMaterials(node))
if (normalized) { if (normalized) {
patchedNodes[id] = normalized patchedNodes[id] = normalized
} }
@@ -153,6 +337,14 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
patchedNodes[id] = normalized patchedNodes[id] = normalized
} }
} }
if (node.type === 'wall') {
patchedNodes[id] = migrateWallSurfaceMaterials(patchedNodes[id])
}
if (node.type === 'roof') {
patchedNodes[id] = migrateRoofSurfaceMaterials(patchedNodes[id])
}
} }
return patchedNodes as Record<string, AnyNode> return patchedNodes as Record<string, AnyNode>
} }
@@ -439,6 +631,7 @@ let prevNodesSnapshot: Record<AnyNodeId, AnyNode> | null = null
export function clearSceneHistory() { export function clearSceneHistory() {
useScene.temporal.getState().clear() useScene.temporal.getState().clear()
resetSceneHistoryPauseDepth()
prevPastLength = 0 prevPastLength = 0
prevFutureLength = 0 prevFutureLength = 0
prevNodesSnapshot = null prevNodesSnapshot = null
@@ -458,8 +651,9 @@ useScene.temporal.subscribe((state) => {
// Capture the previous snapshot before RAF fires // Capture the previous snapshot before RAF fires
const snapshotBefore = prevNodesSnapshot const snapshotBefore = prevNodesSnapshot
// Use RAF to ensure all middleware and store updates are complete // Defer to a microtask so the scene store has settled before we diff,
requestAnimationFrame(() => { // but still mark walls/items dirty before the next paint.
queueMicrotask(() => {
const currentNodes = useScene.getState().nodes const currentNodes = useScene.getState().nodes
const { markDirty } = useScene.getState() const { markDirty } = useScene.getState()
+141 -64
View File
@@ -4,12 +4,90 @@ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
import type { AnyNodeId, FenceNode } from '../../schema' import type { AnyNodeId, FenceNode } from '../../schema'
import useScene from '../../store/use-scene' import useScene from '../../store/use-scene'
import { getWallCurveFrameAt, getWallCurveLength } from '../wall/wall-curve'
type FencePart = { type FencePart = {
position: [number, number, number] position: [number, number, number]
rotationY?: number
scale: [number, number, number] scale: [number, number, number]
} }
const MIN_CURVE_SEGMENT_LENGTH = 0.18
function createFencePartGeometry(part: FencePart) {
const geometry = new THREE.BoxGeometry(1, 1, 1)
geometry.scale(part.scale[0], part.scale[1], part.scale[2])
if (part.rotationY) {
geometry.rotateY(part.rotationY)
}
geometry.translate(part.position[0], part.position[1], part.position[2])
applyFenceUVs(geometry)
return geometry
}
function getFencePointAt(fence: FenceNode, t: number) {
const frame = getWallCurveFrameAt(fence, t)
return {
point: frame.point,
tangentAngle: Math.atan2(frame.tangent.y, frame.tangent.x),
}
}
function createStraightFenceSpanPart(
start: [number, number],
end: [number, number],
centerY: number,
height: number,
depth: number,
): FencePart | null {
const dx = end[0] - start[0]
const dz = end[1] - start[1]
const length = Math.hypot(dx, dz)
if (length <= 1e-4) {
return null
}
return {
position: [(start[0] + end[0]) / 2, centerY, (start[1] + end[1]) / 2],
rotationY: -Math.atan2(dz, dx),
scale: [length, height, depth],
}
}
function createFenceCurveSpanParts(
fence: FenceNode,
startT: number,
endT: number,
centerY: number,
height: number,
depth: number,
): FencePart[] {
const parts: FencePart[] = []
const frameCount = Math.max(
1,
Math.ceil((getWallCurveLength(fence) * Math.max(1e-4, endT - startT)) / MIN_CURVE_SEGMENT_LENGTH),
)
let previous = getFencePointAt(fence, startT)
for (let index = 1; index <= frameCount; index += 1) {
const t = startT + (endT - startT) * (index / frameCount)
const current = getFencePointAt(fence, t)
const segment = createStraightFenceSpanPart(
[previous.point.x, previous.point.y],
[current.point.x, current.point.y],
centerY,
height,
depth,
)
if (segment) {
parts.push(segment)
}
previous = current
}
return parts
}
function applyFenceUVs(geometry: THREE.BufferGeometry) { function applyFenceUVs(geometry: THREE.BufferGeometry) {
const position = geometry.getAttribute('position') const position = geometry.getAttribute('position')
const normal = geometry.getAttribute('normal') const normal = geometry.getAttribute('normal')
@@ -20,26 +98,13 @@ function applyFenceUVs(geometry: THREE.BufferGeometry) {
let minX = Number.POSITIVE_INFINITY let minX = Number.POSITIVE_INFINITY
let minY = Number.POSITIVE_INFINITY let minY = Number.POSITIVE_INFINITY
let minZ = Number.POSITIVE_INFINITY let minZ = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let maxY = Number.NEGATIVE_INFINITY
let maxZ = Number.NEGATIVE_INFINITY
for (let index = 0; index < position.count; index += 1) { for (let index = 0; index < position.count; index += 1) {
const px = position.getX(index) minX = Math.min(minX, position.getX(index))
const py = position.getY(index) minY = Math.min(minY, position.getY(index))
const pz = position.getZ(index) minZ = Math.min(minZ, position.getZ(index))
minX = Math.min(minX, px)
minY = Math.min(minY, py)
minZ = Math.min(minZ, pz)
maxX = Math.max(maxX, px)
maxY = Math.max(maxY, py)
maxZ = Math.max(maxZ, pz)
} }
const width = Math.max(maxX - minX, 0.001)
const height = Math.max(maxY - minY, 0.001)
const depth = Math.max(maxZ - minZ, 0.001)
for (let index = 0; index < position.count; index += 1) { for (let index = 0; index < position.count; index += 1) {
const px = position.getX(index) const px = position.getX(index)
const py = position.getY(index) const py = position.getY(index)
@@ -52,14 +117,14 @@ function applyFenceUVs(geometry: THREE.BufferGeometry) {
let v = 0 let v = 0
if (ny >= nx && ny >= nz) { if (ny >= nx && ny >= nz) {
u = (px - minX) / width u = px - minX
v = (pz - minZ) / depth v = pz - minZ
} else if (nx >= nz) { } else if (nx >= nz) {
u = (pz - minZ) / depth u = pz - minZ
v = (py - minY) / height v = py - minY
} else { } else {
u = (px - minX) / width u = px - minX
v = (py - minY) / height v = py - minY
} }
uvs[index * 2] = u uvs[index * 2] = u
@@ -84,10 +149,7 @@ function getStyleDefaults(style: FenceNode['style']) {
function createFenceParts(fence: FenceNode): FencePart[] { function createFenceParts(fence: FenceNode): FencePart[] {
const parts: FencePart[] = [] const parts: FencePart[] = []
const length = Math.max( const length = Math.max(getWallCurveLength(fence), 0.01)
Math.hypot(fence.end[0] - fence.start[0], fence.end[1] - fence.start[1]),
0.01,
)
const panelDepth = Math.max(fence.thickness, 0.03) const panelDepth = Math.max(fence.thickness, 0.03)
const clearance = Math.max(fence.groundClearance, 0) const clearance = Math.max(fence.groundClearance, 0)
const styleDefaults = getStyleDefaults(fence.style) const styleDefaults = getStyleDefaults(fence.style)
@@ -100,31 +162,39 @@ function createFenceParts(fence: FenceNode): FencePart[] {
const isFloating = fence.baseStyle === 'floating' const isFloating = fence.baseStyle === 'floating'
const baseY = isFloating ? clearance : 0 const baseY = isFloating ? clearance : 0
const effectiveBaseHeight = baseHeight const effectiveBaseHeight = baseHeight
const startInsetT = Math.min(0.499, edgeInset / length)
const endInsetT = Math.max(0.501, 1 - edgeInset / length)
if (!isFloating) { if (!isFloating) {
parts.push({ parts.push(
position: [0, baseY + effectiveBaseHeight / 2, 0], ...createFenceCurveSpanParts(
scale: [length, effectiveBaseHeight, panelDepth * 1.05], fence,
}) 0,
parts.push({ 1,
position: [0, baseY + effectiveBaseHeight + verticalHeight * 0.15, 0], baseY + effectiveBaseHeight / 2,
scale: [length, topRailHeight * 0.8, panelDepth * 0.35], effectiveBaseHeight,
}) panelDepth * 1.05,
),
)
parts.push(
...createFenceCurveSpanParts(
fence,
0,
1,
baseY + effectiveBaseHeight + verticalHeight * 0.15,
topRailHeight * 0.8,
panelDepth * 0.35,
),
)
} }
const count = Math.max(2, Math.floor((length - edgeInset * 2) / spacing) + 1) const count = Math.max(2, Math.floor((length - edgeInset * 2) / spacing) + 1)
const step = count > 1 ? (length - edgeInset * 2) / (count - 1) : 0
const startX = -length / 2 + edgeInset
const verticalY = baseY + effectiveBaseHeight + verticalHeight / 2 const verticalY = baseY + effectiveBaseHeight + verticalHeight / 2
for (let index = 0; index < count; index += 1) { for (let index = 0; index < count; index += 1) {
const x = count === 1 ? 0 : startX + step * index const t = count === 1 ? 0.5 : startInsetT + (endInsetT - startInsetT) * (index / (count - 1))
let posX = x const frame = getFencePointAt(fence, t)
const isEdgePost = index === 0 || index === count - 1 const isEdgePost = index === 0 || index === count - 1
if (count > 1) {
if (index === 0) posX = -length / 2 + edgeInset + postWidth / 2
else if (index === count - 1) posX = length / 2 - edgeInset - postWidth / 2
}
const postHeight = const postHeight =
isFloating && isEdgePost isFloating && isEdgePost
? effectiveBaseHeight + verticalHeight + topRailHeight + clearance ? effectiveBaseHeight + verticalHeight + topRailHeight + clearance
@@ -132,21 +202,34 @@ function createFenceParts(fence: FenceNode): FencePart[] {
const postY = isFloating && isEdgePost ? postHeight / 2 : verticalY const postY = isFloating && isEdgePost ? postHeight / 2 : verticalY
parts.push({ parts.push({
position: [posX, postY, 0], position: [frame.point.x, postY, frame.point.y],
rotationY: -frame.tangentAngle,
scale: [postWidth, postHeight, Math.max(panelDepth * 0.35, 0.012)], scale: [postWidth, postHeight, Math.max(panelDepth * 0.35, 0.012)],
}) })
} }
parts.push({ parts.push(
position: [0, baseY + effectiveBaseHeight + verticalHeight + topRailHeight / 2, 0], ...createFenceCurveSpanParts(
scale: [length, topRailHeight, Math.max(panelDepth * 0.55, 0.018)], fence,
}) 0,
1,
baseY + effectiveBaseHeight + verticalHeight + topRailHeight / 2,
topRailHeight,
Math.max(panelDepth * 0.55, 0.018),
),
)
if (isFloating) { if (isFloating) {
parts.push({ parts.push(
position: [0, baseY + effectiveBaseHeight + topRailHeight / 2, 0], ...createFenceCurveSpanParts(
scale: [length, topRailHeight, Math.max(panelDepth * 0.55, 0.018)], fence,
}) 0,
1,
baseY + effectiveBaseHeight + topRailHeight / 2,
topRailHeight,
Math.max(panelDepth * 0.55, 0.018),
),
)
} }
return parts return parts
@@ -154,16 +237,14 @@ function createFenceParts(fence: FenceNode): FencePart[] {
function generateFenceGeometry(fence: FenceNode) { function generateFenceGeometry(fence: FenceNode) {
const parts = createFenceParts(fence) const parts = createFenceParts(fence)
const geometries = parts.map((part) => { const geometries = parts.map(createFencePartGeometry)
const geometry = new THREE.BoxGeometry(1, 1, 1)
geometry.scale(part.scale[0], part.scale[1], part.scale[2])
geometry.translate(part.position[0], part.position[1], part.position[2])
return geometry
})
const merged = mergeGeometries(geometries, false) ?? new THREE.BufferGeometry() const merged = mergeGeometries(geometries, false) ?? new THREE.BufferGeometry()
geometries.forEach((geometry) => geometry.dispose()) geometries.forEach((geometry) => geometry.dispose())
applyFenceUVs(merged) const mergedUv = merged.getAttribute('uv')
if (mergedUv) {
merged.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(mergedUv.array), 2))
}
merged.computeVertexNormals() merged.computeVertexNormals()
return merged return merged
} }
@@ -178,12 +259,8 @@ function updateFenceGeometry(fenceId: FenceNode['id']) {
const newGeometry = generateFenceGeometry(node) const newGeometry = generateFenceGeometry(node)
mesh.geometry.dispose() mesh.geometry.dispose()
mesh.geometry = newGeometry mesh.geometry = newGeometry
mesh.position.set(0, 0, 0)
const centerX = (node.start[0] + node.end[0]) / 2 mesh.rotation.set(0, 0, 0)
const centerZ = (node.start[1] + node.end[1]) / 2
const angle = Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0])
mesh.position.set(centerX, 0, centerZ)
mesh.rotation.set(0, -angle, 0)
} }
export const FenceSystem = () => { export const FenceSystem = () => {
+38 -1
View File
@@ -11,7 +11,7 @@ import useScene from '../../store/use-scene'
const csgEvaluator = new Evaluator() const csgEvaluator = new Evaluator()
csgEvaluator.useGroups = true csgEvaluator.useGroups = true
;(csgEvaluator as any).consolidateGroups = false // shared dummyMats across brushes causes consolidation to misalign groupIndices vs groupOrder indices → crash ;(csgEvaluator as any).consolidateGroups = false // shared dummyMats across brushes causes consolidation to misalign groupIndices vs groupOrder indices → crash
csgEvaluator.attributes = ['position', 'normal'] csgEvaluator.attributes = ['position', 'normal', 'uv']
function prepareBrushForCSG(brush: Brush) { function prepareBrushForCSG(brush: Brush) {
brush.geometry.computeBoundsTree = computeBoundsTree brush.geometry.computeBoundsTree = computeBoundsTree
@@ -25,6 +25,7 @@ const _position = new THREE.Vector3()
const _quaternion = new THREE.Quaternion() const _quaternion = new THREE.Quaternion()
const _scale = new THREE.Vector3(1, 1, 1) const _scale = new THREE.Vector3(1, 1, 1)
const _yAxis = new THREE.Vector3(0, 1, 0) const _yAxis = new THREE.Vector3(0, 1, 0)
const _uvFaceNormal = new THREE.Vector3()
// Pending merged-roof updates carried across frames (for throttling) // Pending merged-roof updates carried across frames (for throttling)
const pendingRoofUpdates = new Set<AnyNodeId>() const pendingRoofUpdates = new Set<AnyNodeId>()
@@ -251,6 +252,7 @@ function updateMergedRoofGeometry(
g.materialIndex = mapRoofGroupMaterialIndex(g.materialIndex, resultMaterials, matToIndex) g.materialIndex = mapRoofGroupMaterialIndex(g.materialIndex, resultMaterials, matToIndex)
} }
ensureUv2Attribute(resultGeo)
resultGeo.computeVertexNormals() resultGeo.computeVertexNormals()
mergedMesh.geometry.dispose() mergedMesh.geometry.dispose()
mergedMesh.geometry = resultGeo mergedMesh.geometry = resultGeo
@@ -641,6 +643,7 @@ export function generateRoofSegmentGeometry(node: RoofSegmentNode): THREE.Buffer
wallBrush.geometry.dispose() wallBrush.geometry.dispose()
innerBrush.geometry.dispose() innerBrush.geometry.dispose()
ensureUv2Attribute(resultGeo)
resultGeo.computeVertexNormals() resultGeo.computeVertexNormals()
return resultGeo return resultGeo
} }
@@ -936,6 +939,7 @@ function createGeometryFromFaces(
): THREE.BufferGeometry { ): THREE.BufferGeometry {
const positions: number[] = [] const positions: number[] = []
const normals: number[] = [] const normals: number[] = []
const uvs: number[] = []
const indices: number[] = [] const indices: number[] = []
const groups: { start: number; count: number; materialIndex: number }[] = [] const groups: { start: number; count: number; materialIndex: number }[] = []
let vertexCount = 0 let vertexCount = 0
@@ -974,6 +978,10 @@ function createGeometryFromFaces(
normals.push(normal.x, normal.y, normal.z) normals.push(normal.x, normal.y, normal.z)
normals.push(normal.x, normal.y, normal.z) normals.push(normal.x, normal.y, normal.z)
pushRoofUv(uvs, p0, normal)
pushRoofUv(uvs, fi, normal)
pushRoofUv(uvs, fi1, normal)
indices.push(vertexCount, vertexCount + 1, vertexCount + 2) indices.push(vertexCount, vertexCount + 1, vertexCount + 2)
faceVertexCount += 3 faceVertexCount += 3
@@ -990,6 +998,7 @@ function createGeometryFromFaces(
const geometry = new THREE.BufferGeometry() const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3)) geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3))
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
geometry.setIndex(indices) geometry.setIndex(indices)
for (const g of groups) { for (const g of groups) {
@@ -999,6 +1008,34 @@ function createGeometryFromFaces(
// Merge identical vertices to optimize geometry for CSG and create clean topology // Merge identical vertices to optimize geometry for CSG and create clean topology
const mergedGeo = mergeVertices(geometry, 1e-4) const mergedGeo = mergeVertices(geometry, 1e-4)
geometry.dispose() geometry.dispose()
ensureUv2Attribute(mergedGeo)
return mergedGeo return mergedGeo
} }
function pushRoofUv(uvs: number[], point: THREE.Vector3, normal: THREE.Vector3) {
_uvFaceNormal.copy(normal).normalize()
const absX = Math.abs(_uvFaceNormal.x)
const absY = Math.abs(_uvFaceNormal.y)
const absZ = Math.abs(_uvFaceNormal.z)
if (absY >= absX && absY >= absZ) {
uvs.push(point.x, point.z)
return
}
if (absX >= absZ) {
uvs.push(point.z, point.y)
return
}
uvs.push(point.x, point.y)
}
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
const uv = geometry.getAttribute('uv')
if (!uv) return
geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(uv.array), 2))
}
@@ -12,6 +12,10 @@ import { syncAutoStairOpenings } from './stair-opening-sync'
const pendingStairUpdates = new Set<AnyNodeId>() const pendingStairUpdates = new Set<AnyNodeId>()
const MAX_STAIRS_PER_FRAME = 2 const MAX_STAIRS_PER_FRAME = 2
const MAX_SEGMENTS_PER_FRAME = 4 const MAX_SEGMENTS_PER_FRAME = 4
const STAIR_TREAD_MATERIAL_INDEX = 0
const STAIR_SIDE_MATERIAL_INDEX = 1
const _uvPosition = new THREE.Vector3()
const _uvNormal = new THREE.Vector3()
// ============================================================================ // ============================================================================
// STAIR SYSTEM // STAIR SYSTEM
@@ -198,7 +202,7 @@ function generateStairSegmentGeometry(
shape.lineTo(0, 0) shape.lineTo(0, 0)
const geometry = new THREE.ExtrudeGeometry(shape, { const extrudedGeometry = new THREE.ExtrudeGeometry(shape, {
steps: 1, steps: 1,
depth: width, depth: width,
bevelEnabled: false, bevelEnabled: false,
@@ -209,7 +213,16 @@ function generateStairSegmentGeometry(
const matrix = new THREE.Matrix4() const matrix = new THREE.Matrix4()
matrix.makeRotationY(-Math.PI / 2) matrix.makeRotationY(-Math.PI / 2)
matrix.setPosition(width / 2, 0, 0) matrix.setPosition(width / 2, 0, 0)
geometry.applyMatrix4(matrix) extrudedGeometry.applyMatrix4(matrix)
extrudedGeometry.computeVertexNormals()
const geometry = extrudedGeometry.toNonIndexed() ?? extrudedGeometry
if (geometry !== extrudedGeometry) {
extrudedGeometry.dispose()
}
applyStairSegmentUvs(geometry)
ensureUv2Attribute(geometry)
return geometry return geometry
} }
@@ -219,6 +232,7 @@ function updateStairSegmentGeometry(node: StairSegmentNode, mesh: THREE.Mesh) {
const absoluteHeight = computeAbsoluteHeight(node) const absoluteHeight = computeAbsoluteHeight(node)
const newGeometry = generateStairSegmentGeometry(node, absoluteHeight) const newGeometry = generateStairSegmentGeometry(node, absoluteHeight)
applyStraightStairMaterialGroups(newGeometry)
mesh.geometry.dispose() mesh.geometry.dispose()
mesh.geometry = newGeometry mesh.geometry = newGeometry
@@ -363,6 +377,7 @@ function updateMergedStairGeometry(
} }
const merged = mergeGeometries(geometries, false) ?? createEmptyGeometry() const merged = mergeGeometries(geometries, false) ?? createEmptyGeometry()
applyStraightStairMaterialGroups(merged)
replaceMeshGeometry(mergedMesh, merged) replaceMeshGeometry(mergedMesh, merged)
// Dispose individual geometries // Dispose individual geometries
@@ -371,6 +386,108 @@ function updateMergedStairGeometry(
} }
} }
function applyStraightStairMaterialGroups(geometry: THREE.BufferGeometry) {
const position = geometry.getAttribute('position')
if (!position || position.count < 3) {
geometry.clearGroups()
return
}
const index = geometry.getIndex()
const triangleCount = index ? index.count / 3 : position.count / 3
if (!Number.isFinite(triangleCount) || triangleCount <= 0) {
geometry.clearGroups()
return
}
const triangleMaterials: number[] = new Array(triangleCount)
const v0 = new THREE.Vector3()
const v1 = new THREE.Vector3()
const v2 = new THREE.Vector3()
const edge1 = new THREE.Vector3()
const edge2 = new THREE.Vector3()
const normal = new THREE.Vector3()
for (let triangleIndex = 0; triangleIndex < triangleCount; triangleIndex++) {
const vertexOffset = triangleIndex * 3
const a = index ? index.getX(vertexOffset) : vertexOffset
const b = index ? index.getX(vertexOffset + 1) : vertexOffset + 1
const c = index ? index.getX(vertexOffset + 2) : vertexOffset + 2
v0.fromBufferAttribute(position, a)
v1.fromBufferAttribute(position, b)
v2.fromBufferAttribute(position, c)
edge1.subVectors(v1, v0)
edge2.subVectors(v2, v0)
normal.crossVectors(edge1, edge2)
triangleMaterials[triangleIndex] =
normal.lengthSq() > 0 && normal.normalize().y > 0.75
? STAIR_TREAD_MATERIAL_INDEX
: STAIR_SIDE_MATERIAL_INDEX
}
geometry.clearGroups()
let currentMaterial = triangleMaterials[0]
let groupStart = 0
for (let triangleIndex = 1; triangleIndex < triangleMaterials.length; triangleIndex++) {
const materialIndex = triangleMaterials[triangleIndex]
if (materialIndex === currentMaterial) continue
geometry.addGroup(groupStart * 3, (triangleIndex - groupStart) * 3, currentMaterial)
groupStart = triangleIndex
currentMaterial = materialIndex
}
geometry.addGroup(
groupStart * 3,
(triangleMaterials.length - groupStart) * 3,
currentMaterial ?? STAIR_SIDE_MATERIAL_INDEX,
)
}
function applyStairSegmentUvs(geometry: THREE.BufferGeometry) {
const position = geometry.getAttribute('position')
const normal = geometry.getAttribute('normal')
if (!position || !normal || position.count === 0) {
geometry.deleteAttribute('uv')
return
}
const uv: number[] = []
for (let index = 0; index < position.count; index++) {
_uvPosition.fromBufferAttribute(position, index)
_uvNormal.fromBufferAttribute(normal, index).normalize()
const absX = Math.abs(_uvNormal.x)
const absY = Math.abs(_uvNormal.y)
const absZ = Math.abs(_uvNormal.z)
if (absY >= absX && absY >= absZ) {
uv.push(_uvPosition.x, _uvPosition.z)
} else if (absX >= absZ) {
uv.push(_uvPosition.z, _uvPosition.y)
} else {
uv.push(_uvPosition.x, _uvPosition.y)
}
}
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2))
}
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
const uv = geometry.getAttribute('uv')
if (!uv) return
geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(uv.array), 2))
}
// ============================================================================ // ============================================================================
// SEGMENT CHAINING // SEGMENT CHAINING
// ============================================================================ // ============================================================================
@@ -441,6 +558,8 @@ function rotateXZ(x: number, z: number, angle: number): [number, number] {
function createEmptyGeometry(): THREE.BufferGeometry { function createEmptyGeometry(): THREE.BufferGeometry {
const geometry = new THREE.BufferGeometry() const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
geometry.addGroup(0, 0, STAIR_TREAD_MATERIAL_INDEX)
geometry.addGroup(0, 0, STAIR_SIDE_MATERIAL_INDEX)
return geometry return geometry
} }
+3 -3
View File
@@ -1,10 +1,10 @@
import type { Point2D } from './wall-mitering' import type { Point2D } from './wall-mitering'
import type { WallNode } from '../../schema' import type { FenceNode, WallNode } from '../../schema'
const CURVE_EPSILON = 1e-6 const CURVE_EPSILON = 1e-6
const DEFAULT_SAMPLE_SEGMENTS = 24 const DEFAULT_SAMPLE_SEGMENTS = 24
type WallCurveLike = Pick<WallNode, 'start' | 'end' | 'curveOffset'> type WallCurveLike = Pick<WallNode | FenceNode, 'start' | 'end' | 'curveOffset'>
type CurveFrame = { type CurveFrame = {
point: Point2D point: Point2D
@@ -198,7 +198,7 @@ export function getWallCurveLength(wall: WallCurveLike, segments = DEFAULT_SAMPL
} }
export function getWallSurfacePolygon( export function getWallSurfacePolygon(
wall: Pick<WallNode, 'start' | 'end' | 'curveOffset' | 'thickness'>, wall: Pick<WallNode | FenceNode, 'start' | 'end' | 'curveOffset' | 'thickness'>,
segments = DEFAULT_SAMPLE_SEGMENTS, segments = DEFAULT_SAMPLE_SEGMENTS,
miterOverrides?: WallSurfaceMiterOverrides, miterOverrides?: WallSurfaceMiterOverrides,
) { ) {
+216 -2
View File
@@ -7,20 +7,30 @@ import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manage
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync' import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
import type { AnyNode, AnyNodeId, WallNode } from '../../schema' import type { AnyNode, AnyNodeId, WallNode } from '../../schema'
import useScene from '../../store/use-scene' import useScene from '../../store/use-scene'
import { DEFAULT_WALL_HEIGHT, getWallPlanFootprint, getWallThickness } from './wall-footprint'
import { getWallCurveFrameAt, getWallSurfacePolygon, isCurvedWall } from './wall-curve' import { getWallCurveFrameAt, getWallSurfacePolygon, isCurvedWall } from './wall-curve'
import { DEFAULT_WALL_HEIGHT, getWallPlanFootprint, getWallThickness } from './wall-footprint'
import { import {
calculateLevelMiters, calculateLevelMiters,
getAdjacentWallIds, getAdjacentWallIds,
getWallMiterBoundaryPoints, getWallMiterBoundaryPoints,
type Point2D, type Point2D,
type WallMiterData,
pointToKey, pointToKey,
type WallMiterData,
} from './wall-mitering' } from './wall-mitering'
// Reusable CSG evaluator for better performance // Reusable CSG evaluator for better performance
const csgEvaluator = new Evaluator() const csgEvaluator = new Evaluator()
const CURVED_WALL_3D_ENDPOINT_INSET = 0.0015 const CURVED_WALL_3D_ENDPOINT_INSET = 0.0015
const WALL_FACE_NORMAL_Y_EPSILON = 0.6
const WALL_FACE_EDGE_DISTANCE_EPSILON = 0.003
type WallBoundaryEdgeTag = 'front' | 'back' | 'base'
type TaggedWallBoundaryEdge = {
start: THREE.Vector2
end: THREE.Vector2
tag: WallBoundaryEdgeTag
}
function ensureUv2Attribute(geometry: THREE.BufferGeometry) { function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
const uv = geometry.getAttribute('uv') const uv = geometry.getAttribute('uv')
@@ -78,6 +88,207 @@ function insetCurvedWallBoundaryPointsFor3D(
return next return next
} }
function addTaggedWallBoundaryEdge(
edges: TaggedWallBoundaryEdge[],
points: { x: number; z: number }[],
startIndex: number,
endIndex: number,
tag: WallBoundaryEdgeTag,
) {
const start = points[startIndex]
const end = points[endIndex]
if (!(start && end)) return
if (Math.hypot(end.x - start.x, end.z - start.z) < 1e-6) return
edges.push({
start: new THREE.Vector2(start.x, start.z),
end: new THREE.Vector2(end.x, end.z),
tag,
})
}
function buildTaggedWallBoundaryEdges(
wall: WallNode,
localPoints: { x: number; z: number }[],
miterData: WallMiterData,
): TaggedWallBoundaryEdge[] {
if (localPoints.length < 2) return []
const edges: TaggedWallBoundaryEdge[] = []
if (isCurvedWall(wall)) {
const sidePointCount = Math.floor(localPoints.length / 2)
if (sidePointCount < 2) return edges
for (let index = 0; index < sidePointCount - 1; index += 1) {
addTaggedWallBoundaryEdge(edges, localPoints, index, index + 1, 'back')
}
addTaggedWallBoundaryEdge(edges, localPoints, sidePointCount - 1, sidePointCount, 'base')
for (let index = sidePointCount; index < localPoints.length - 1; index += 1) {
addTaggedWallBoundaryEdge(edges, localPoints, index, index + 1, 'front')
}
addTaggedWallBoundaryEdge(edges, localPoints, localPoints.length - 1, 0, 'base')
return edges
}
const startKey = pointToKey({ x: wall.start[0], y: wall.start[1] })
const startJunction = miterData.junctionData.get(startKey)?.get(wall.id)
const startLeftIndex = startJunction ? localPoints.length - 2 : localPoints.length - 1
const endLeftIndex = startJunction ? localPoints.length - 3 : localPoints.length - 2
addTaggedWallBoundaryEdge(edges, localPoints, 0, 1, 'back')
for (let index = 1; index < endLeftIndex; index += 1) {
addTaggedWallBoundaryEdge(edges, localPoints, index, index + 1, 'base')
}
addTaggedWallBoundaryEdge(edges, localPoints, endLeftIndex, startLeftIndex, 'front')
for (let index = startLeftIndex; index < localPoints.length - 1; index += 1) {
addTaggedWallBoundaryEdge(edges, localPoints, index, index + 1, 'base')
}
addTaggedWallBoundaryEdge(edges, localPoints, localPoints.length - 1, 0, 'base')
return edges
}
function distanceToWallBoundaryEdge(point: THREE.Vector2, edge: TaggedWallBoundaryEdge): number {
const edgeDx = edge.end.x - edge.start.x
const edgeDz = edge.end.y - edge.start.y
const pointDx = point.x - edge.start.x
const pointDz = point.y - edge.start.y
const edgeLengthSq = edgeDx * edgeDx + edgeDz * edgeDz
if (edgeLengthSq < 1e-12) {
return point.distanceTo(edge.start)
}
const t = THREE.MathUtils.clamp((pointDx * edgeDx + pointDz * edgeDz) / edgeLengthSq, 0, 1)
const closestX = edge.start.x + edgeDx * t
const closestZ = edge.start.y + edgeDz * t
return Math.hypot(point.x - closestX, point.y - closestZ)
}
function getWallFaceMaterialIndex(
wall: Pick<WallNode, 'frontSide' | 'backSide'>,
face: 'front' | 'back',
): 0 | 1 | 2 {
const semantic = face === 'front' ? wall.frontSide : wall.backSide
const fallback = face === 'front' ? 1 : 2
if (semantic === 'interior') return 1
if (semantic === 'exterior') return 2
return fallback
}
function assignWallMaterialGroups(
geometry: THREE.BufferGeometry,
wall: WallNode,
boundaryEdges: TaggedWallBoundaryEdge[],
) {
const position = geometry.getAttribute('position')
if (!position) return
const index = geometry.getIndex()
const triangleCount = index ? Math.floor(index.count / 3) : Math.floor(position.count / 3)
if (triangleCount === 0) {
geometry.clearGroups()
return
}
const triangleMaterials = new Array<number>(triangleCount).fill(0)
const a = new THREE.Vector3()
const b = new THREE.Vector3()
const c = new THREE.Vector3()
const ab = new THREE.Vector3()
const ac = new THREE.Vector3()
const normal = new THREE.Vector3()
const centroid = new THREE.Vector3()
const projectedCentroid = new THREE.Vector2()
const maxBoundaryDistance = Math.max(
getWallThickness(wall) * 0.02,
WALL_FACE_EDGE_DISTANCE_EPSILON,
)
for (let triangleIndex = 0; triangleIndex < triangleCount; triangleIndex += 1) {
const baseIndex = triangleIndex * 3
const ia = index ? index.getX(baseIndex) : baseIndex
const ib = index ? index.getX(baseIndex + 1) : baseIndex + 1
const ic = index ? index.getX(baseIndex + 2) : baseIndex + 2
a.fromBufferAttribute(position, ia)
b.fromBufferAttribute(position, ib)
c.fromBufferAttribute(position, ic)
ab.subVectors(b, a)
ac.subVectors(c, a)
normal.crossVectors(ab, ac)
if (normal.lengthSq() < 1e-12) {
triangleMaterials[triangleIndex] = 0
continue
}
normal.normalize()
if (Math.abs(normal.y) >= WALL_FACE_NORMAL_Y_EPSILON) {
triangleMaterials[triangleIndex] = 0
continue
}
centroid
.copy(a)
.add(b)
.add(c)
.multiplyScalar(1 / 3)
projectedCentroid.set(centroid.x, centroid.z)
let nearestTag: WallBoundaryEdgeTag | null = null
let nearestDistance = Number.POSITIVE_INFINITY
for (const edge of boundaryEdges) {
const distance = distanceToWallBoundaryEdge(projectedCentroid, edge)
if (distance < nearestDistance) {
nearestDistance = distance
nearestTag = edge.tag
}
}
if (!nearestTag || nearestDistance > maxBoundaryDistance) {
triangleMaterials[triangleIndex] = 0
continue
}
if (nearestTag === 'base') {
triangleMaterials[triangleIndex] = 0
continue
}
triangleMaterials[triangleIndex] = getWallFaceMaterialIndex(wall, nearestTag)
}
geometry.clearGroups()
let currentMaterial = triangleMaterials[0] ?? 0
let groupStart = 0
for (let triangleIndex = 1; triangleIndex < triangleCount; triangleIndex += 1) {
const materialIndex = triangleMaterials[triangleIndex] ?? 0
if (materialIndex === currentMaterial) continue
geometry.addGroup(groupStart * 3, (triangleIndex - groupStart) * 3, currentMaterial)
groupStart = triangleIndex
currentMaterial = materialIndex
}
geometry.addGroup(groupStart * 3, (triangleCount - groupStart) * 3, currentMaterial)
}
// ============================================================================ // ============================================================================
// WALL SYSTEM // WALL SYSTEM
// ============================================================================ // ============================================================================
@@ -252,6 +463,7 @@ export function generateExtrudedWall(
// Convert polygon to local coordinates // Convert polygon to local coordinates
const localPoints = polyPoints.map(worldToLocal) const localPoints = polyPoints.map(worldToLocal)
const boundaryEdges = buildTaggedWallBoundaryEdges(wallNode, localPoints, miterData)
// Build THREE.js shape // Build THREE.js shape
// Shape uses (x, y) where we map: shape.x = local.x, shape.y = -local.z // Shape uses (x, y) where we map: shape.x = local.x, shape.y = -local.z
@@ -272,6 +484,7 @@ export function generateExtrudedWall(
// Rotate so extrusion direction (Z) becomes height direction (Y) // Rotate so extrusion direction (Z) becomes height direction (Y)
geometry.rotateX(-Math.PI / 2) geometry.rotateX(-Math.PI / 2)
geometry.computeVertexNormals() geometry.computeVertexNormals()
assignWallMaterialGroups(geometry, wallNode, boundaryEdges)
ensureUv2Attribute(geometry) ensureUv2Attribute(geometry)
// Apply CSG subtraction for cutouts (doors/windows) // Apply CSG subtraction for cutouts (doors/windows)
@@ -307,6 +520,7 @@ export function generateExtrudedWall(
const resultGeometry = resultBrush.geometry const resultGeometry = resultBrush.geometry
resultGeometry.computeVertexNormals() resultGeometry.computeVertexNormals()
assignWallMaterialGroups(resultGeometry, wallNode, boundaryEdges)
ensureUv2Attribute(resultGeometry) ensureUv2Attribute(resultGeometry)
return resultGeometry return resultGeometry
+5 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "@pascal-app/editor", "name": "@pascal-app/editor",
"version": "0.5.1", "version": "0.6.0",
"description": "Pascal building editor component", "description": "Pascal building editor component",
"type": "module", "type": "module",
"exports": { "exports": {
@@ -11,8 +11,8 @@
"check-types": "tsc --noEmit" "check-types": "tsc --noEmit"
}, },
"peerDependencies": { "peerDependencies": {
"@pascal-app/core": "^0.5.1", "@pascal-app/core": "^0.6.0",
"@pascal-app/viewer": "^0.5.1", "@pascal-app/viewer": "^0.6.0",
"@react-three/drei": "^10", "@react-three/drei": "^10",
"@react-three/fiber": "^9", "@react-three/fiber": "^9",
"next": ">=15", "next": ">=15",
@@ -50,8 +50,8 @@
"zustand": "^5.0.11" "zustand": "^5.0.11"
}, },
"devDependencies": { "devDependencies": {
"@pascal-app/core": "^0.5.1", "@pascal-app/core": "^0.6.0",
"@pascal-app/viewer": "^0.5.1", "@pascal-app/viewer": "^0.6.0",
"@pascal/typescript-config": "*", "@pascal/typescript-config": "*",
"@types/howler": "^2.2.12", "@types/howler": "^2.2.12",
"@types/node": "^22.19.12", "@types/node": "^22.19.12",
@@ -49,9 +49,13 @@ export function FloatingActionMenu() {
const mode = useEditor((s) => s.mode) const mode = useEditor((s) => s.mode)
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered) const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint) const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint)
const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint)
const curvingFence = useEditor((s) => s.curvingFence)
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
const setMovingWallEndpoint = useEditor((s) => s.setMovingWallEndpoint) const setMovingWallEndpoint = useEditor((s) => s.setMovingWallEndpoint)
const setMovingFenceEndpoint = useEditor((s) => s.setMovingFenceEndpoint)
const setCurvingWall = useEditor((s) => s.setCurvingWall) const setCurvingWall = useEditor((s) => s.setCurvingWall)
const setCurvingFence = useEditor((s) => s.setCurvingFence)
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
const setEditingHole = useEditor((s) => s.setEditingHole) const setEditingHole = useEditor((s) => s.setEditingHole)
@@ -128,12 +132,26 @@ export function FloatingActionMenu() {
groupRef.current.position.set(center.x, box.max.y + yOffset, center.z) groupRef.current.position.set(center.x, box.max.y + yOffset, center.z)
} }
if (node?.type === 'wall') { if (node?.type === 'wall' || node?.type === 'fence') {
const wall = node as WallNode const segment = node as WallNode | FenceNode
const wallLength = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1])
const endpointYOffset = 0.35 const endpointYOffset = 0.35
const startWorld = obj.localToWorld(new THREE.Vector3(0, 0, 0)) const startWorld =
const endWorld = obj.localToWorld(new THREE.Vector3(wallLength, 0, 0)) node.type === 'wall'
? obj.localToWorld(new THREE.Vector3(0, 0, 0))
: obj.localToWorld(new THREE.Vector3(segment.start[0], 0, segment.start[1]))
const endWorld =
node.type === 'wall'
? obj.localToWorld(
new THREE.Vector3(
Math.hypot(
segment.end[0] - segment.start[0],
segment.end[1] - segment.start[1],
),
0,
0,
),
)
: obj.localToWorld(new THREE.Vector3(segment.end[0], 0, segment.end[1]))
if (startEndpointGroupRef.current) { if (startEndpointGroupRef.current) {
startEndpointGroupRef.current.position.set( startEndpointGroupRef.current.position.set(
@@ -180,22 +198,35 @@ export function FloatingActionMenu() {
const handleCurve = useCallback( const handleCurve = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
if (!canCurveSelectedWall || !node || node.type !== 'wall') return if (!node) return
sfxEmitter.emit('sfx:item-pick') sfxEmitter.emit('sfx:item-pick')
setCurvingWall(node) if (node.type === 'wall') {
if (!canCurveSelectedWall) return
setCurvingWall(node)
} else if (node.type === 'fence') {
setCurvingFence(node)
} else {
return
}
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, },
[canCurveSelectedWall, node, setCurvingWall, setSelection], [canCurveSelectedWall, node, setCurvingFence, setCurvingWall, setSelection],
) )
const handleEndpointMove = useCallback( const handleEndpointMove = useCallback(
(endpoint: 'start' | 'end', e: React.MouseEvent) => { (endpoint: 'start' | 'end', e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
if (!(node && node.type === 'wall')) return if (!node) return
sfxEmitter.emit('sfx:item-pick') sfxEmitter.emit('sfx:item-pick')
setMovingWallEndpoint({ wall: node, endpoint }) if (node.type === 'wall') {
setMovingWallEndpoint({ wall: node, endpoint })
} else if (node.type === 'fence') {
setMovingFenceEndpoint({ fence: node, endpoint })
} else {
return
}
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, },
[node, setMovingWallEndpoint, setSelection], [node, setMovingFenceEndpoint, setMovingWallEndpoint, setSelection],
) )
const handleDuplicate = useCallback( const handleDuplicate = useCallback(
@@ -396,7 +427,9 @@ export function FloatingActionMenu() {
if ( if (
!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') || !(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') ||
movingWallEndpoint movingWallEndpoint ||
movingFenceEndpoint ||
curvingFence
) )
return null return null
@@ -413,7 +446,11 @@ export function FloatingActionMenu() {
> >
<NodeActionMenu <NodeActionMenu
onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined} onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
onCurve={canCurveSelectedWall ? handleCurve : undefined} onCurve={
node?.type === 'fence' || (node?.type === 'wall' && canCurveSelectedWall)
? handleCurve
: undefined
}
onDelete={handleDelete} onDelete={handleDelete}
onDuplicate={ onDuplicate={
node && !DELETE_ONLY_TYPES.includes(node.type) && !HOLE_TYPES.includes(node.type) node && !DELETE_ONLY_TYPES.includes(node.type) && !HOLE_TYPES.includes(node.type)
@@ -426,7 +463,7 @@ export function FloatingActionMenu() {
/> />
</Html> </Html>
</group> </group>
{node?.type === 'wall' && ( {(node?.type === 'wall' || node?.type === 'fence') && (
<> <>
<group ref={startEndpointGroupRef}> <group ref={startEndpointGroupRef}>
<Html <Html
@@ -435,7 +472,7 @@ export function FloatingActionMenu() {
zIndexRange={[100, 0]} zIndexRange={[100, 0]}
> >
<button <button
aria-label="Move wall start" aria-label={node.type === 'wall' ? 'Move wall start' : 'Move fence start'}
className={`pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border bg-background/95 shadow-lg backdrop-blur-md transition-colors ${ className={`pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border bg-background/95 shadow-lg backdrop-blur-md transition-colors ${
altPressed altPressed
? 'border-amber-500/80 bg-amber-500/15 text-amber-100 hover:bg-amber-500/20 hover:text-white' ? 'border-amber-500/80 bg-amber-500/15 text-amber-100 hover:bg-amber-500/20 hover:text-white'
@@ -443,7 +480,11 @@ export function FloatingActionMenu() {
}`} }`}
onClick={(e) => handleEndpointMove('start', e)} onClick={(e) => handleEndpointMove('start', e)}
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
title="Move wall start (Alt to detach)" title={
node.type === 'wall'
? 'Move wall start (Alt to detach)'
: 'Move fence start (Alt to detach)'
}
type="button" type="button"
> >
<Move className="h-4 w-4" /> <Move className="h-4 w-4" />
@@ -457,7 +498,7 @@ export function FloatingActionMenu() {
zIndexRange={[100, 0]} zIndexRange={[100, 0]}
> >
<button <button
aria-label="Move wall end" aria-label={node.type === 'wall' ? 'Move wall end' : 'Move fence end'}
className={`pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border bg-background/95 shadow-lg backdrop-blur-md transition-colors ${ className={`pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border bg-background/95 shadow-lg backdrop-blur-md transition-colors ${
altPressed altPressed
? 'border-amber-500/80 bg-amber-500/15 text-amber-100 hover:bg-amber-500/20 hover:text-white' ? 'border-amber-500/80 bg-amber-500/15 text-amber-100 hover:bg-amber-500/20 hover:text-white'
@@ -465,7 +506,11 @@ export function FloatingActionMenu() {
}`} }`}
onClick={(e) => handleEndpointMove('end', e)} onClick={(e) => handleEndpointMove('end', e)}
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
title="Move wall end (Alt to detach)" title={
node.type === 'wall'
? 'Move wall end (Alt to detach)'
: 'Move fence end (Alt to detach)'
}
type="button" type="button"
> >
<Move className="h-4 w-4" /> <Move className="h-4 w-4" />
@@ -159,6 +159,12 @@ type FloorplanViewport = {
width: number width: number
} }
function floorplanViewportEquals(a: FloorplanViewport | null, b: FloorplanViewport | null) {
if (a === b) return true
if (!(a && b)) return false
return a.centerX === b.centerX && a.centerY === b.centerY && a.width === b.width
}
type SvgPoint = { type SvgPoint = {
x: number x: number
y: number y: number
@@ -4772,8 +4778,9 @@ const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered) const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
const movingNode = useEditor((state) => state.movingNode) const movingNode = useEditor((state) => state.movingNode)
const curvingWall = useEditor((state) => state.curvingWall) const curvingWall = useEditor((state) => state.curvingWall)
const curvingFence = useEditor((state) => state.curvingFence)
if (!isFloorplanHovered || movingNode || curvingWall) { if (!isFloorplanHovered || movingNode || curvingWall || curvingFence) {
return null return null
} }
@@ -4976,6 +4983,7 @@ export function FloorplanPanel() {
const setMode = useEditor((state) => state.setMode) const setMode = useEditor((state) => state.setMode)
const movingNode = useEditor((state) => state.movingNode) const movingNode = useEditor((state) => state.movingNode)
const curvingWall = useEditor((state) => state.curvingWall) const curvingWall = useEditor((state) => state.curvingWall)
const curvingFence = useEditor((state) => state.curvingFence)
const phase = useEditor((state) => state.phase) const phase = useEditor((state) => state.phase)
const mode = useEditor((state) => state.mode) const mode = useEditor((state) => state.mode)
const setPhase = useEditor((state) => state.setPhase) const setPhase = useEditor((state) => state.setPhase)
@@ -5650,6 +5658,7 @@ export function FloorplanPanel() {
const isCeilingMoveActive = movingNode?.type === 'ceiling' const isCeilingMoveActive = movingNode?.type === 'ceiling'
const isWallMoveActive = movingNode?.type === 'wall' const isWallMoveActive = movingNode?.type === 'wall'
const isWallCurveActive = curvingWall?.type === 'wall' const isWallCurveActive = curvingWall?.type === 'wall'
const isFenceCurveActive = curvingFence?.type === 'fence'
const isItemPlacementPreviewActive = const isItemPlacementPreviewActive =
(mode === 'build' && tool === 'item') || movingNode?.type === 'item' (mode === 'build' && tool === 'item') || movingNode?.type === 'item'
const isFloorItemBuildActive = mode === 'build' && tool === 'item' && !selectedItem?.attachTo const isFloorItemBuildActive = mode === 'build' && tool === 'item' && !selectedItem?.attachTo
@@ -5661,6 +5670,7 @@ export function FloorplanPanel() {
isCeilingMoveActive || isCeilingMoveActive ||
isWallMoveActive || isWallMoveActive ||
isWallCurveActive || isWallCurveActive ||
isFenceCurveActive ||
isFloorItemBuildActive || isFloorItemBuildActive ||
isFloorItemMoveActive isFloorItemMoveActive
const floorplanPreviewStairSegment = useMemo( const floorplanPreviewStairSegment = useMemo(
@@ -6155,12 +6165,12 @@ export function FloorplanPanel() {
if (levelChanged) { if (levelChanged) {
previousLevelIdRef.current = levelId ?? null previousLevelIdRef.current = levelId ?? null
hasUserAdjustedViewportRef.current = false hasUserAdjustedViewportRef.current = false
setViewport(fittedViewport) setViewport((current) => (floorplanViewportEquals(current, fittedViewport) ? current : fittedViewport))
return return
} }
if (!hasUserAdjustedViewportRef.current) { if (!hasUserAdjustedViewportRef.current) {
setViewport(fittedViewport) setViewport((current) => (floorplanViewportEquals(current, fittedViewport) ? current : fittedViewport))
} }
}, [fittedViewport, levelId]) }, [fittedViewport, levelId])
+218 -10
View File
@@ -7,8 +7,16 @@ import {
spatialGridManager, spatialGridManager,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer' import { type HoverStyles, InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer'
import { memo, type ReactNode, useCallback, useEffect, useRef, useState } from 'react' import {
memo,
type ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react'
import { ViewerOverlay } from '../../components/viewer-overlay' import { ViewerOverlay } from '../../components/viewer-overlay'
import { ViewerZoneSystem } from '../../components/viewer-zone-system' import { ViewerZoneSystem } from '../../components/viewer-zone-system'
import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context' import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context'
@@ -64,6 +72,21 @@ const CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY = 'editor-camera-controls-hint-
const DELETE_CURSOR_BADGE_COLOR = '#ef4444' const DELETE_CURSOR_BADGE_COLOR = '#ef4444'
const DELETE_CURSOR_BADGE_OFFSET_X = 14 const DELETE_CURSOR_BADGE_OFFSET_X = 14
const DELETE_CURSOR_BADGE_OFFSET_Y = 14 const DELETE_CURSOR_BADGE_OFFSET_Y = 14
const PAINT_CURSOR_BADGE_COLOR = '#f59e0b'
const PAINT_CURSOR_BADGE_DISABLED_COLOR = '#94a3b8'
const PAINT_CURSOR_BADGE_OFFSET_X = 14
const PAINT_CURSOR_BADGE_OFFSET_Y = 14
const EDITOR_HOVER_STYLES: HoverStyles = {
default: { visibleColor: 0x00_aaff, hiddenColor: 0xf3_ff47, strength: 5, pulse: true },
delete: { visibleColor: 0xef_4444, hiddenColor: 0x99_1b1b, strength: 6, pulse: false },
'paint-ready': { visibleColor: 0xf5_9e0b, hiddenColor: 0xfd_e068, strength: 5, pulse: true },
'paint-disabled': {
visibleColor: 0x94_a3b8,
hiddenColor: 0x47_5569,
strength: 4,
pulse: false,
},
}
/** /**
* Wire up module-level singletons (spatial grid, space detection, SFX) for * Wire up module-level singletons (spatial grid, space detection, SFX) for
@@ -502,6 +525,50 @@ function DeleteCursorBadge({ position }: { position: { x: number; y: number } })
) )
} }
function PaintCursorBadge({
position,
label,
disabled,
icon,
}: {
position: { x: number; y: number }
label: string
disabled: boolean
icon: string
}) {
const accentColor = disabled ? PAINT_CURSOR_BADGE_DISABLED_COLOR : PAINT_CURSOR_BADGE_COLOR
return (
<div
aria-hidden="true"
className="pointer-events-none absolute z-40"
style={{
left: position.x + PAINT_CURSOR_BADGE_OFFSET_X,
top: position.y + PAINT_CURSOR_BADGE_OFFSET_Y,
}}
>
<div
className="flex items-center gap-2 rounded-xl border border-white/5 bg-zinc-900/95 px-3 py-2 shadow-[0_8px_16px_-4px_rgba(0,0,0,0.3),0_4px_8px_-4px_rgba(0,0,0,0.2)]"
style={{
boxShadow: `0 8px 16px -4px rgba(0,0,0,0.3), 0 4px 8px -4px rgba(0,0,0,0.2), 0 0 18px ${accentColor}22`,
}}
>
<Icon
aria-hidden="true"
className="drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
color={accentColor}
height={16}
icon={icon}
width={16}
/>
<span className="font-medium text-[11px]" style={{ color: accentColor }}>
{label}
</span>
</div>
</div>
)
}
// ── Viewer scene content: memoized so <Viewer> doesn't re-render on mode/viewMode changes ── // ── Viewer scene content: memoized so <Viewer> doesn't re-render on mode/viewMode changes ──
const ViewerSceneContent = memo(function ViewerSceneContent({ const ViewerSceneContent = memo(function ViewerSceneContent({
@@ -553,31 +620,165 @@ function DeleteCursorLayer({
isVersionPreviewMode: boolean isVersionPreviewMode: boolean
}) { }) {
const mode = useEditor((s) => s.mode) const mode = useEditor((s) => s.mode)
const [position, setPosition] = useState<{ x: number; y: number } | null>(null) const badgeRef = useRef<HTMLDivElement>(null)
const active = mode === 'delete' && !isVersionPreviewMode const active = mode === 'delete' && !isVersionPreviewMode
useEffect(() => { useEffect(() => {
if (!active) { if (!active) {
setPosition(null) if (badgeRef.current) {
badgeRef.current.style.display = 'none'
}
return return
} }
const el = containerRef.current const el = containerRef.current
if (!el) return if (!el) return
let frame = 0
let nextX = 0
let nextY = 0
const badge = badgeRef.current
const flushPosition = () => {
frame = 0
if (!badge) return
badge.style.display = 'block'
badge.style.transform = `translate(${nextX + DELETE_CURSOR_BADGE_OFFSET_X}px, ${nextY + DELETE_CURSOR_BADGE_OFFSET_Y}px)`
}
const onMove = (e: PointerEvent) => { const onMove = (e: PointerEvent) => {
const rect = el.getBoundingClientRect() const rect = el.getBoundingClientRect()
setPosition({ x: e.clientX - rect.left, y: e.clientY - rect.top }) nextX = e.clientX - rect.left
nextY = e.clientY - rect.top
if (frame === 0) {
frame = window.requestAnimationFrame(flushPosition)
}
}
const onLeave = () => {
if (frame !== 0) {
window.cancelAnimationFrame(frame)
frame = 0
}
if (badge) {
badge.style.display = 'none'
}
} }
const onLeave = () => setPosition(null)
el.addEventListener('pointermove', onMove) el.addEventListener('pointermove', onMove)
el.addEventListener('pointerleave', onLeave) el.addEventListener('pointerleave', onLeave)
return () => { return () => {
if (frame !== 0) {
window.cancelAnimationFrame(frame)
}
el.removeEventListener('pointermove', onMove) el.removeEventListener('pointermove', onMove)
el.removeEventListener('pointerleave', onLeave) el.removeEventListener('pointerleave', onLeave)
} }
}, [active, containerRef]) }, [active, containerRef])
if (!(active && position)) return null if (!active) return null
return <DeleteCursorBadge position={position} />
return (
<div
className="pointer-events-none"
ref={badgeRef}
style={{ display: 'none', position: 'absolute', left: 0, top: 0 }}
>
<DeleteCursorBadge position={{ x: 0, y: 0 }} />
</div>
)
}
function PaintCursorLayer({
containerRef,
isVersionPreviewMode,
}: {
containerRef: React.RefObject<HTMLDivElement | null>
isVersionPreviewMode: boolean
}) {
const mode = useEditor((s) => s.mode)
const activePaintMaterial = useEditor((s) => s.activePaintMaterial)
const activePaintTarget = useEditor((s) => s.activePaintTarget)
const badgeRef = useRef<HTMLDivElement>(null)
const active = mode === 'material-paint' && !isVersionPreviewMode
useEffect(() => {
if (!active) {
if (badgeRef.current) {
badgeRef.current.style.display = 'none'
}
return
}
const el = containerRef.current
if (!el) return
let frame = 0
let nextX = 0
let nextY = 0
const badge = badgeRef.current
const flushPosition = () => {
frame = 0
if (!badge) return
badge.style.display = 'block'
badge.style.transform = `translate(${nextX + PAINT_CURSOR_BADGE_OFFSET_X}px, ${nextY + PAINT_CURSOR_BADGE_OFFSET_Y}px)`
}
const onMove = (e: PointerEvent) => {
const rect = el.getBoundingClientRect()
nextX = e.clientX - rect.left
nextY = e.clientY - rect.top
if (frame === 0) {
frame = window.requestAnimationFrame(flushPosition)
}
}
const onLeave = () => {
if (frame !== 0) {
window.cancelAnimationFrame(frame)
frame = 0
}
if (badge) {
badge.style.display = 'none'
}
}
el.addEventListener('pointermove', onMove)
el.addEventListener('pointerleave', onLeave)
return () => {
if (frame !== 0) {
window.cancelAnimationFrame(frame)
}
el.removeEventListener('pointermove', onMove)
el.removeEventListener('pointerleave', onLeave)
}
}, [active, containerRef])
const hasMaterial = Boolean(
activePaintMaterial &&
(activePaintMaterial.material !== undefined ||
activePaintMaterial.materialPreset !== undefined),
)
const label = !hasMaterial ? 'Choose material' : `Paint ${activePaintTarget}`
const icon = 'mdi:format-color-fill'
useLayoutEffect(() => {
if (!active && badgeRef.current) {
badgeRef.current.style.display = 'none'
}
}, [active])
if (!active) return null
return (
<div
className="pointer-events-none"
ref={badgeRef}
style={{ display: 'none', position: 'absolute', left: 0, top: 0 }}
>
<PaintCursorBadge
disabled={!hasMaterial}
icon={icon}
label={label}
position={{ x: 0, y: 0 }}
/>
</div>
)
} }
// ── Viewer canvas: memoized, subscribes to viewMode/floorplanPaneRatio internally ── // ── Viewer canvas: memoized, subscribes to viewMode/floorplanPaneRatio internally ──
@@ -685,6 +886,10 @@ const ViewerCanvas = memo(function ViewerCanvas({
containerRef={viewer3dRef} containerRef={viewer3dRef}
isVersionPreviewMode={isVersionPreviewMode} isVersionPreviewMode={isVersionPreviewMode}
/> />
<PaintCursorLayer
containerRef={viewer3dRef}
isVersionPreviewMode={isVersionPreviewMode}
/>
{!showLoader && isCameraControlsHintVisible && !isFirstPersonMode ? ( {!showLoader && isCameraControlsHintVisible && !isFirstPersonMode ? (
<ViewerCanvasControlsHint <ViewerCanvasControlsHint
isPreviewMode={isPreviewMode} isPreviewMode={isPreviewMode}
@@ -692,7 +897,10 @@ const ViewerCanvas = memo(function ViewerCanvas({
/> />
) : null} ) : null}
<SelectionPersistenceManager enabled={hasLoadedInitialScene && !showLoader} /> <SelectionPersistenceManager enabled={hasLoadedInitialScene && !showLoader} />
<Viewer selectionManager={isFirstPersonMode ? 'default' : 'custom'}> <Viewer
hoverStyles={EDITOR_HOVER_STYLES}
selectionManager={isFirstPersonMode ? 'default' : 'custom'}
>
<ViewerSceneContent <ViewerSceneContent
isFirstPersonMode={isFirstPersonMode} isFirstPersonMode={isFirstPersonMode}
isLoading={isLoading} isLoading={isLoading}
@@ -825,7 +1033,7 @@ export default function Editor({
const showLoader = isLoading || isSceneLoading const showLoader = isLoading || isSceneLoading
const previewViewerContent = ( const previewViewerContent = (
<Viewer selectionManager="default"> <Viewer hoverStyles={EDITOR_HOVER_STYLES} selectionManager="default">
<ExportManager /> <ExportManager />
<ViewerZoneSystem /> <ViewerZoneSystem />
<CeilingSystem /> <CeilingSystem />
@@ -2,19 +2,56 @@ import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
type BuildingNode, type BuildingNode,
type CeilingNode,
emitter, emitter,
type FenceNode,
getMaterialPresetByRef,
type ItemNode, type ItemNode,
type NodeEvent, type NodeEvent,
type RoofEvent,
type RoofNode,
type RoofSegmentEvent,
resolveLevelId, resolveLevelId,
resolveMaterial,
type SlabNode,
type StairEvent,
type StairNode,
type StairSegmentEvent,
type StairSurfaceMaterialRole,
sceneRegistry, sceneRegistry,
useScene, useScene,
type WallEvent,
type WallNode,
type WallSurfaceSide,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import {
applyMaterialPresetToMaterials,
createMaterial,
createMaterialFromPresetRef,
getRoofMaterialArray,
getStairBodyMaterials,
getStairRailingMaterial,
getVisibleWallMaterials,
useViewer,
} from '@pascal-app/viewer'
import { useCallback, useEffect, useRef } from 'react' import { useCallback, useEffect, useRef } from 'react'
import { Color, type Material, type Mesh, type Object3D } from 'three' import { type BufferGeometry, Color, type Material, type Mesh, type Object3D } from 'three'
import {
type ActivePaintMaterial,
buildRoofSurfaceMaterialPatch,
buildSingleSurfaceMaterialPatch,
buildStairSurfaceMaterialPatch,
buildWallSurfaceMaterialPatch,
hasActivePaintMaterial,
resolveActivePaintMaterialFromSelection,
} from '../../lib/material-paint'
import { sfxEmitter } from '../../lib/sfx-bus' import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor, { type Phase, type StructureLayer } from './../../store/use-editor' import useEditor, {
type MaterialTargetRole,
type Phase,
type StructureLayer,
} from './../../store/use-editor'
import { boxSelectHandled } from '../tools/select/box-select-tool' import { boxSelectHandled } from '../tools/select/box-select-tool'
const isNodeInCurrentLevel = (node: AnyNode): boolean => { const isNodeInCurrentLevel = (node: AnyNode): boolean => {
@@ -44,6 +81,16 @@ type ModifierKeys = {
ctrl: boolean ctrl: boolean
} }
type PaintPreviewCleanup = () => void
type PaintInteraction = {
key: string
apply: (() => void) | null
hoverMode: HoverHighlightMode
hoveredId: AnyNodeId
preview: (() => PaintPreviewCleanup | null) | null
}
interface SelectionStrategy { interface SelectionStrategy {
types: SelectableNodeType[] types: SelectableNodeType[]
handleSelect: (node: AnyNode, nativeEvent?: any, modifierKeys?: ModifierKeys) => void handleSelect: (node: AnyNode, nativeEvent?: any, modifierKeys?: ModifierKeys) => void
@@ -68,6 +115,310 @@ export const resolveBuildingId = (
return null return null
} }
function resolveWallMaterialTarget(event: WallEvent): WallSurfaceSide | null {
const materialIndex = getIntersectionMaterialIndex(getEventObject(event), event.faceIndex)
if (materialIndex === 1) return 'interior'
if (materialIndex === 2) return 'exterior'
const normalZ = event.normal?.[2]
const localZ = event.localPosition[2]
const thickness = event.node.thickness ?? 0.1
if (
normalZ === undefined ||
Math.abs(normalZ) < 0.65 ||
Math.abs(localZ) < Math.max(thickness * 0.2, 0.01)
) {
return null
}
const hitFace = localZ >= 0 ? 'front' : 'back'
const semantic = hitFace === 'front' ? event.node.frontSide : event.node.backSide
if (semantic === 'interior' || semantic === 'exterior') {
return semantic
}
return hitFace === 'front' ? 'interior' : 'exterior'
}
function resolveStairMaterialTarget(
event: StairEvent | StairSegmentEvent,
): StairSurfaceMaterialRole | null {
const hitObjectName = event.nativeEvent.object?.name ?? ''
const materialIndex = getIntersectionMaterialIndex(getEventObject(event), event.faceIndex)
if (hitObjectName.startsWith('stair-railing')) {
return 'railing'
}
if (hitObjectName.startsWith('stair-side')) {
return 'side'
}
if (materialIndex === 0) {
return 'tread'
}
if (materialIndex === 1) {
return 'side'
}
const normalY = event.normal?.[1]
if (normalY !== undefined && normalY > 0.75) {
return 'tread'
}
if (normalY !== undefined && Math.abs(normalY) <= 0.75) {
return 'side'
}
return null
}
function resolveRoofMaterialTarget(
event: RoofEvent | RoofSegmentEvent,
): 'top' | 'edge' | 'wall' | null {
const materialIndex = getIntersectionMaterialIndex(getEventObject(event), event.faceIndex)
if (materialIndex === 3) return 'top'
if (materialIndex === 0) return 'edge'
if (materialIndex === 1 || materialIndex === 2) return 'wall'
const normalY = event.normal?.[1]
if (normalY !== undefined && normalY > 0.35) return 'top'
if (normalY !== undefined && Math.abs(normalY) <= 0.35) return 'edge'
if (normalY !== undefined && normalY < -0.35) return 'wall'
return null
}
function getEventObject(event: NodeEvent): Object3D {
const eventWithObject = event as NodeEvent & { object?: Object3D }
return eventWithObject.object ?? event.nativeEvent.object
}
function getIntersectionMaterialIndex(
object: Object3D,
faceIndex: number | undefined,
): number | undefined {
if (faceIndex === undefined) return undefined
const geometry = (object as Mesh).geometry as BufferGeometry | undefined
if (!geometry || geometry.groups.length === 0) return undefined
const triangleStart = faceIndex * 3
const group = geometry.groups.find(
(entry) => triangleStart >= entry.start && triangleStart < entry.start + entry.count,
)
return group?.materialIndex
}
function getRegisteredNodeObject(nodeId: string): Object3D | null {
return sceneRegistry.nodes.get(nodeId) ?? null
}
function getRegisteredMesh(nodeId: string): Mesh | null {
const object = getRegisteredNodeObject(nodeId)
return object && (object as Mesh).isMesh ? (object as Mesh) : null
}
function previewMeshMaterial(mesh: Mesh, material: Material | Material[]): PaintPreviewCleanup {
const previousMaterial = mesh.material
mesh.material = material
return () => {
mesh.material = previousMaterial
}
}
function previewCursor(cursor: string): PaintPreviewCleanup {
const previousCursor = document.body.style.cursor
document.body.style.cursor = cursor
return () => {
document.body.style.cursor = previousCursor
}
}
function getSingleSurfacePreviewMaterial(material: ActivePaintMaterial): Material | null {
if (material.materialPreset) {
return createMaterialFromPresetRef(material.materialPreset)
}
if (material.material) {
return createMaterial(material.material)
}
return null
}
function applyWallPaintPreview(
node: WallNode,
role: WallSurfaceSide,
material: ActivePaintMaterial,
): PaintPreviewCleanup | null {
const mesh = getRegisteredMesh(node.id)
if (!mesh) return null
const previewNode = {
...node,
...buildWallSurfaceMaterialPatch(node, role, material.material, material.materialPreset),
}
return previewMeshMaterial(mesh, getVisibleWallMaterials(previewNode))
}
function applyRoofPaintPreview(
node: RoofNode,
role: 'top' | 'edge' | 'wall',
material: ActivePaintMaterial,
): PaintPreviewCleanup | null {
const root = getRegisteredNodeObject(node.id)
const mesh = root?.getObjectByName('merged-roof') as Mesh | undefined
if (!mesh) return null
const previewNode = {
...node,
...buildRoofSurfaceMaterialPatch(node, role, material.material, material.materialPreset),
}
const previewMaterial = getRoofMaterialArray(previewNode)
if (!previewMaterial) return null
return previewMeshMaterial(mesh, previewMaterial)
}
function applyStairPaintPreview(
node: StairNode,
role: StairSurfaceMaterialRole,
material: ActivePaintMaterial,
): PaintPreviewCleanup | null {
const root = getRegisteredNodeObject(node.id)
if (!root) return null
const previewNode = {
...node,
...buildStairSurfaceMaterialPatch(node, role, material.material, material.materialPreset),
}
const bodyMaterials = getStairBodyMaterials(previewNode)
const railingMaterial = getStairRailingMaterial(previewNode)
const restores: PaintPreviewCleanup[] = []
root.traverse((object) => {
if (!(object as Mesh).isMesh) return
const mesh = object as Mesh
if (mesh.name.startsWith('stair-railing')) {
restores.push(previewMeshMaterial(mesh, railingMaterial))
return
}
if (Array.isArray(mesh.material) && mesh.material.length === 2) {
restores.push(previewMeshMaterial(mesh, bodyMaterials))
return
}
if (mesh.name === 'merged-stair') {
restores.push(previewMeshMaterial(mesh, bodyMaterials))
return
}
if (mesh.name.startsWith('stair-side')) {
restores.push(previewMeshMaterial(mesh, bodyMaterials[1]))
}
})
if (restores.length === 0) return null
return () => {
for (let index = restores.length - 1; index >= 0; index -= 1) {
restores[index]?.()
}
}
}
function applySingleSurfacePaintPreview(
node: FenceNode | SlabNode | CeilingNode,
material: ActivePaintMaterial,
): PaintPreviewCleanup | null {
if (node.type === 'ceiling') {
const root = getRegisteredMesh(node.id)
const overlay = root?.getObjectByName('ceiling-grid') as Mesh | undefined
if (!root || !overlay) return null
const previewColor =
getMaterialPresetByRef(material.materialPreset)?.mapProperties.color ??
resolveMaterial(material.material).color ??
'#999999'
const previousRootMaterial = root.material
const previousOverlayMaterial = overlay.material
const rootPreviewMaterial = Array.isArray(previousRootMaterial)
? previousRootMaterial.map((entry) => entry.clone())
: previousRootMaterial.clone()
const overlayPreviewMaterial = Array.isArray(previousOverlayMaterial)
? previousOverlayMaterial.map((entry) => entry.clone())
: previousOverlayMaterial.clone()
const applyColor = (input: Material | Material[]) => {
const materials = Array.isArray(input) ? input : [input]
for (const entry of materials) {
const materialWithColor = entry as Material & { color?: Color; needsUpdate?: boolean }
if (materialWithColor.color instanceof Color) {
materialWithColor.color = new Color(previewColor)
}
materialWithColor.needsUpdate = true
}
}
applyColor(rootPreviewMaterial)
applyColor(overlayPreviewMaterial)
root.material = rootPreviewMaterial
overlay.material = overlayPreviewMaterial
return () => {
root.material = previousRootMaterial
overlay.material = previousOverlayMaterial
}
}
const mesh = getRegisteredMesh(node.id)
if (!mesh) return null
const previewMaterial = getSingleSurfacePreviewMaterial(material)
if (!previewMaterial) return null
if (node.type === 'slab') {
const slabMaterial = previewMaterial.clone()
applyMaterialPresetToMaterials(slabMaterial, getMaterialPresetByRef(material.materialPreset))
const previewMeshMaterialInput = slabMaterial as Material & {
alphaMap?: unknown
depthWrite?: boolean
needsUpdate?: boolean
opacity?: number
side?: number
transparent?: boolean
}
previewMeshMaterialInput.transparent = false
previewMeshMaterialInput.opacity = 1
previewMeshMaterialInput.alphaMap = null
previewMeshMaterialInput.depthWrite = true
previewMeshMaterialInput.needsUpdate = true
return previewMeshMaterial(mesh, slabMaterial)
}
return previewMeshMaterial(mesh, previewMaterial)
}
function setSelectedMaterialTargetForNode(node: AnyNode, role: MaterialTargetRole | null) {
if (!role) {
const currentTarget = useEditor.getState().selectedMaterialTarget
if (currentTarget?.nodeId !== node.id) {
useEditor.getState().setSelectedMaterialTarget(null)
}
return
}
useEditor.getState().setSelectedMaterialTarget({
nodeId: node.id as AnyNodeId,
role,
})
}
const HIGHLIGHT_PROFILES = { const HIGHLIGHT_PROFILES = {
delete: { delete: {
color: new Color('#dc2626'), color: new Color('#dc2626'),
@@ -84,6 +435,7 @@ const HIGHLIGHT_PROFILES = {
} as const } as const
type HighlightKind = keyof typeof HIGHLIGHT_PROFILES type HighlightKind = keyof typeof HIGHLIGHT_PROFILES
type HoverHighlightMode = 'default' | 'delete' | 'paint-ready' | 'paint-disabled'
type HighlightableMaterial = Material & { type HighlightableMaterial = Material & {
color?: Color color?: Color
@@ -347,15 +699,295 @@ export const SelectionManager = () => {
const movingNode = useEditor((s) => s.movingNode) const movingNode = useEditor((s) => s.movingNode)
const curvingWall = useEditor((s) => s.curvingWall) const curvingWall = useEditor((s) => s.curvingWall)
const curvingFence = useEditor((s) => s.curvingFence)
useEffect(() => { useEffect(() => {
setHoverHighlightMode(mode === 'delete' ? 'delete' : 'default') const nextHoverMode: HoverHighlightMode = mode === 'delete' ? 'delete' : 'default'
setHoverHighlightMode(nextHoverMode)
return () => { return () => {
setHoverHighlightMode('default') setHoverHighlightMode('default')
} }
}, [mode, setHoverHighlightMode]) }, [mode, setHoverHighlightMode])
useEffect(() => {
if (mode !== 'material-paint') return
if (movingNode || curvingWall) return
let activePreview: { key: string; restore: PaintPreviewCleanup } | null = null
const clearActivePreview = () => {
activePreview?.restore()
activePreview = null
}
const resolveActivePaintMaterial = () =>
useEditor.getState().activePaintMaterial ??
resolveActivePaintMaterialFromSelection({
nodes: useScene.getState().nodes,
selectedId:
useViewer.getState().selection.selectedIds.length === 1
? (useViewer.getState().selection.selectedIds[0] ?? null)
: null,
selectedMaterialTarget: useEditor.getState().selectedMaterialTarget,
})
const getPaintInteraction = (event: NodeEvent): PaintInteraction | null => {
const activePaintMaterial = resolveActivePaintMaterial()
const node = event.node
if (!isNodeInCurrentLevel(node)) return null
if (node.type === 'wall') {
const role = resolveWallMaterialTarget(event as WallEvent)
const compatible = role !== null && hasActivePaintMaterial(activePaintMaterial)
return {
key: `wall:${node.id}:${role ?? 'unsupported'}`,
hoveredId: node.id as AnyNodeId,
hoverMode:
compatible && hasActivePaintMaterial(activePaintMaterial) && role
? 'paint-ready'
: 'paint-disabled',
apply:
compatible && hasActivePaintMaterial(activePaintMaterial)
? () => {
useScene
.getState()
.updateNode(
node.id as AnyNodeId,
buildWallSurfaceMaterialPatch(
node as WallNode,
role!,
activePaintMaterial.material,
activePaintMaterial.materialPreset,
),
)
}
: null,
preview:
compatible && hasActivePaintMaterial(activePaintMaterial) && role
? () => applyWallPaintPreview(node as WallNode, role, activePaintMaterial)
: () => previewCursor('not-allowed'),
}
}
if (node.type === 'roof' || node.type === 'roof-segment') {
const roofNode =
node.type === 'roof'
? node
: node.parentId
? useScene.getState().nodes[node.parentId as AnyNodeId]
: null
if (!roofNode || roofNode.type !== 'roof') return null
const role = resolveRoofMaterialTarget(event as RoofEvent | RoofSegmentEvent)
const compatible = role !== null && hasActivePaintMaterial(activePaintMaterial)
return {
key: `roof:${roofNode.id}:${role ?? 'unsupported'}`,
hoveredId: roofNode.id as AnyNodeId,
hoverMode:
compatible && hasActivePaintMaterial(activePaintMaterial) && role
? 'paint-ready'
: 'paint-disabled',
apply:
compatible && hasActivePaintMaterial(activePaintMaterial)
? () => {
useScene
.getState()
.updateNode(
roofNode.id as AnyNodeId,
buildRoofSurfaceMaterialPatch(
roofNode as RoofNode,
role!,
activePaintMaterial.material,
activePaintMaterial.materialPreset,
),
)
}
: null,
preview:
compatible && hasActivePaintMaterial(activePaintMaterial) && role
? () => applyRoofPaintPreview(roofNode as RoofNode, role, activePaintMaterial)
: () => previewCursor('not-allowed'),
}
}
if (node.type === 'stair' || node.type === 'stair-segment') {
const stairNode =
node.type === 'stair'
? node
: node.parentId
? useScene.getState().nodes[node.parentId as AnyNodeId]
: null
if (!stairNode || stairNode.type !== 'stair') return null
const role = resolveStairMaterialTarget(event as StairEvent | StairSegmentEvent)
const compatible = role !== null && hasActivePaintMaterial(activePaintMaterial)
return {
key: `stair:${stairNode.id}:${role ?? 'unsupported'}`,
hoveredId: stairNode.id as AnyNodeId,
hoverMode:
compatible && hasActivePaintMaterial(activePaintMaterial) && role
? 'paint-ready'
: 'paint-disabled',
apply:
compatible && hasActivePaintMaterial(activePaintMaterial)
? () => {
useScene
.getState()
.updateNode(
stairNode.id as AnyNodeId,
buildStairSurfaceMaterialPatch(
stairNode as StairNode,
role!,
activePaintMaterial.material,
activePaintMaterial.materialPreset,
),
)
}
: null,
preview:
compatible && hasActivePaintMaterial(activePaintMaterial) && role
? () => applyStairPaintPreview(stairNode as StairNode, role, activePaintMaterial)
: () => previewCursor('not-allowed'),
}
}
if (node.type === 'fence' || node.type === 'slab' || node.type === 'ceiling') {
const compatible = hasActivePaintMaterial(activePaintMaterial)
return {
key: `${node.type}:${node.id}:surface`,
hoveredId: node.id as AnyNodeId,
hoverMode: compatible ? 'paint-ready' : 'paint-disabled',
apply: compatible
? () => {
useScene
.getState()
.updateNode(
node.id as AnyNodeId,
buildSingleSurfaceMaterialPatch<FenceNode | SlabNode | CeilingNode>(
activePaintMaterial.material,
activePaintMaterial.materialPreset,
),
)
}
: null,
preview: compatible
? () =>
applySingleSurfacePaintPreview(
node as FenceNode | SlabNode | CeilingNode,
activePaintMaterial,
)
: () => previewCursor('not-allowed'),
}
}
const disabledNodeTypes = ['item', 'window', 'door', 'zone']
if (disabledNodeTypes.includes(node.type)) {
return {
key: `${node.type}:${node.id}:unsupported`,
hoveredId: node.id as AnyNodeId,
hoverMode: 'paint-disabled',
apply: null,
preview: () => previewCursor('not-allowed'),
}
}
return null
}
const onEnter = (event: NodeEvent) => {
if (boxSelectHandled) return
const interaction = getPaintInteraction(event)
if (!interaction) return
event.stopPropagation()
if (activePreview?.key === interaction.key) {
return
}
clearActivePreview()
useViewer.setState({ hoveredId: interaction.hoveredId })
setHoverHighlightMode(interaction.hoverMode)
const restore = interaction.preview?.()
if (restore) {
activePreview = { key: interaction.key, restore }
}
}
const onLeave = (event: NodeEvent) => {
const interaction = getPaintInteraction(event)
if (!interaction) return
if (activePreview?.key !== interaction.key) {
return
}
clearActivePreview()
if (useViewer.getState().hoveredId === interaction.hoveredId) {
useViewer.setState({ hoveredId: null })
}
setHoverHighlightMode('default')
}
const onClick = (event: NodeEvent) => {
if (boxSelectHandled) return
const interaction = getPaintInteraction(event)
if (!interaction) return
event.stopPropagation()
if (!interaction.apply) {
return
}
interaction.apply()
if (activePreview?.key === interaction.key) {
activePreview = null
} else {
clearActivePreview()
}
setHoverHighlightMode(interaction.hoverMode)
}
const allTypes = [
'wall',
'fence',
'item',
'slab',
'ceiling',
'roof',
'roof-segment',
'stair',
'stair-segment',
'window',
'door',
'zone',
] as const
for (const type of allTypes) {
emitter.on(`${type}:enter` as any, onEnter as any)
emitter.on(`${type}:leave` as any, onLeave as any)
emitter.on(`${type}:click` as any, onClick as any)
}
return () => {
for (const type of allTypes) {
emitter.off(`${type}:enter` as any, onEnter as any)
emitter.off(`${type}:leave` as any, onLeave as any)
emitter.off(`${type}:click` as any, onClick as any)
}
clearActivePreview()
useViewer.setState({ hoveredId: null })
setHoverHighlightMode('default')
}
}, [curvingWall, mode, movingNode, setHoverHighlightMode])
useEffect(() => { useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Meta') modifierKeysRef.current.meta = true if (event.key === 'Meta') modifierKeysRef.current.meta = true
@@ -385,7 +1017,7 @@ export const SelectionManager = () => {
useEffect(() => { useEffect(() => {
if (mode !== 'select') return if (mode !== 'select') return
if (movingNode || curvingWall) return if (movingNode || curvingWall || curvingFence) return
const onClick = (event: NodeEvent) => { const onClick = (event: NodeEvent) => {
// Skip if box-select just completed (drag ended over a node) // Skip if box-select just completed (drag ended over a node)
@@ -439,6 +1071,50 @@ export const SelectionManager = () => {
activeStrategy.handleSelect(nodeToSelect, event.nativeEvent, modifierKeysRef.current) activeStrategy.handleSelect(nodeToSelect, event.nativeEvent, modifierKeysRef.current)
let nextMaterialTargetHandled = false
if (node.type === 'wall' && nodeToSelect.type === 'wall') {
setSelectedMaterialTargetForNode(
nodeToSelect,
resolveWallMaterialTarget(event as WallEvent),
)
nextMaterialTargetHandled = true
}
if (
(node.type === 'stair' || node.type === 'stair-segment') &&
nodeToSelect.type === 'stair'
) {
setSelectedMaterialTargetForNode(
nodeToSelect,
resolveStairMaterialTarget(event as StairEvent | StairSegmentEvent),
)
nextMaterialTargetHandled = true
}
if (
(node.type === 'roof' || node.type === 'roof-segment') &&
nodeToSelect.type === 'roof'
) {
setSelectedMaterialTargetForNode(
nodeToSelect,
resolveRoofMaterialTarget(event as RoofEvent | RoofSegmentEvent),
)
nextMaterialTargetHandled = true
}
if (
(node.type === 'fence' || node.type === 'slab' || node.type === 'ceiling') &&
nodeToSelect.type === node.type
) {
setSelectedMaterialTargetForNode(nodeToSelect, 'surface')
nextMaterialTargetHandled = true
}
if (!nextMaterialTargetHandled && useEditor.getState().selectedMaterialTarget) {
useEditor.getState().setSelectedMaterialTarget(null)
}
// Reset the handled flag after a short delay to allow grid:click to be ignored // Reset the handled flag after a short delay to allow grid:click to be ignored
setTimeout(() => { setTimeout(() => {
clickHandledRef.current = false clickHandledRef.current = false
@@ -471,6 +1147,7 @@ export const SelectionManager = () => {
const { phase, structureLayer } = useEditor.getState() const { phase, structureLayer } = useEditor.getState()
const activeStrategy = SELECTION_STRATEGIES[phase] const activeStrategy = SELECTION_STRATEGIES[phase]
if (activeStrategy) activeStrategy.handleDeselect() if (activeStrategy) activeStrategy.handleDeselect()
useEditor.getState().setSelectedMaterialTarget(null)
// When deselecting from zone mode, return to structure select // When deselecting from zone mode, return to structure select
if (phase === 'structure' && structureLayer === 'zones') { if (phase === 'structure' && structureLayer === 'zones') {
@@ -486,12 +1163,12 @@ export const SelectionManager = () => {
}) })
emitter.off('grid:click', onGridClick) emitter.off('grid:click', onGridClick)
} }
}, [curvingWall, mode, movingNode]) }, [curvingFence, curvingWall, mode, movingNode])
// Global double-click handler for auto-switching phases and cross-phase hover // Global double-click handler for auto-switching phases and cross-phase hover
useEffect(() => { useEffect(() => {
if (mode !== 'select') return if (mode !== 'select') return
if (movingNode || curvingWall) return if (movingNode || curvingWall || curvingFence) return
const onEnter = (event: NodeEvent) => { const onEnter = (event: NodeEvent) => {
const node = event.node const node = event.node
@@ -620,7 +1297,7 @@ export const SelectionManager = () => {
emitter.off(`${type}:double-click` as any, onDoubleClick as any) emitter.off(`${type}:double-click` as any, onDoubleClick as any)
}) })
} }
}, [curvingWall, mode, movingNode]) }, [curvingFence, curvingWall, mode, movingNode])
// Delete mode: click-to-delete (sledgehammer tool) // Delete mode: click-to-delete (sledgehammer tool)
useEffect(() => { useEffect(() => {
@@ -704,6 +1381,12 @@ export const SelectionManager = () => {
} }
const SelectionStateSync = () => { const SelectionStateSync = () => {
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
const setSelectedMaterialTarget = useEditor((s) => s.setSelectedMaterialTarget)
const singleSelectedId = useViewer((s) =>
s.selection.selectedIds.length === 1 ? s.selection.selectedIds[0] : null,
)
useEffect(() => { useEffect(() => {
return useScene.subscribe((state) => { return useScene.subscribe((state) => {
const { buildingId, levelId, zoneId, selectedIds } = useViewer.getState().selection const { buildingId, levelId, zoneId, selectedIds } = useViewer.getState().selection
@@ -732,6 +1415,33 @@ const SelectionStateSync = () => {
}) })
}, []) }, [])
useEffect(() => {
if (!selectedMaterialTarget) return
if (!singleSelectedId) {
setSelectedMaterialTarget(null)
return
}
const selectedNode = useScene.getState().nodes[singleSelectedId as AnyNodeId]
if (
!selectedNode ||
(selectedNode.type !== 'wall' &&
selectedNode.type !== 'fence' &&
selectedNode.type !== 'slab' &&
selectedNode.type !== 'ceiling' &&
selectedNode.type !== 'stair' &&
selectedNode.type !== 'roof')
) {
setSelectedMaterialTarget(null)
return
}
if (selectedMaterialTarget.nodeId !== selectedNode.id) {
setSelectedMaterialTarget(null)
}
}, [selectedMaterialTarget, setSelectedMaterialTarget, singleSelectedId])
return null return null
} }
@@ -831,7 +1541,8 @@ const SelectionMaterialSync = () => {
}, [hoverHighlightMode, hoveredId, previewSelectedIds, selectedIds, syncSelectionMaterials]) }, [hoverHighlightMode, hoveredId, previewSelectedIds, selectedIds, syncSelectionMaterials])
useEffect(() => { useEffect(() => {
return useScene.subscribe(() => { return useScene.subscribe((state, prevState) => {
if (state.nodes === prevState.nodes) return
syncSelectionMaterials() syncSelectionMaterials()
}) })
}, [syncSelectionMaterials]) }, [syncSelectionMaterials])
@@ -6,7 +6,7 @@ import { useEffect, useRef } from 'react'
* Imperatively toggles the Three.js visibility of roof objects based on the * Imperatively toggles the Three.js visibility of roof objects based on the
* editor selection — without causing React re-renders in RoofRenderer. * editor selection — without causing React re-renders in RoofRenderer.
* *
* When a roof (or one of its segments) is selected: * When a roof-segment is selected:
* - merged-roof mesh is hidden * - merged-roof mesh is hidden
* - segments-wrapper group is shown (individual segments visible for editing) * - segments-wrapper group is shown (individual segments visible for editing)
* - all children are marked dirty so RoofSystem rebuilds their geometry * - all children are marked dirty so RoofSystem rebuilds their geometry
@@ -22,14 +22,14 @@ export const RoofEditSystem = () => {
useEffect(() => { useEffect(() => {
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
// Collect which roof nodes should be in "edit mode" // Collect which roof nodes should be in "edit mode".
// Selecting the roof itself should keep the merged visual intact so
// material appearance does not jump between merged and per-segment meshes.
const activeRoofIds = new Set<string>() const activeRoofIds = new Set<string>()
for (const id of selectedIds) { for (const id of selectedIds) {
const node = nodes[id as AnyNodeId] const node = nodes[id as AnyNodeId]
if (!node) continue if (!node) continue
if (node.type === 'roof') { if (node.type === 'roof-segment' && node.parentId) {
activeRoofIds.add(id)
} else if (node.type === 'roof-segment' && node.parentId) {
activeRoofIds.add(node.parentId) activeRoofIds.add(node.parentId)
} }
} }
@@ -0,0 +1,179 @@
'use client'
import {
type AnyNodeId,
emitter,
type FenceNode,
type GridEvent,
getClampedWallCurveOffset,
getMaxWallCurveOffset,
getWallChordFrame,
getWallMidpointHandlePoint,
normalizeWallCurveOffset,
pauseSceneHistory,
resumeSceneHistory,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
import { getWallGridStep, snapScalarToGrid } from '../wall/wall-drafting'
export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
const activatedAtRef = useRef<number>(Date.now())
const originalCurveOffsetRef = useRef(getClampedWallCurveOffset(node))
const previousCurveOffsetRef = useRef<number | null>(null)
const shiftPressedRef = useRef(false)
const previewOffsetRef = useRef<number>(originalCurveOffsetRef.current)
const initialHandle = getWallMidpointHandlePoint(node)
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>([
initialHandle.x,
0,
initialHandle.y,
])
const exitCurveMode = useCallback(() => {
useEditor.getState().setCurvingFence(null)
}, [])
useEffect(() => {
const nodeId = node.id
const originalCurveOffset = originalCurveOffsetRef.current
const chord = getWallChordFrame(node)
const maxCurveOffset = getMaxWallCurveOffset(node)
pauseSceneHistory(useScene)
let wasCommitted = false
const applyPreview = (curveOffset: number) => {
if (previewOffsetRef.current === curveOffset) {
return
}
previewOffsetRef.current = curveOffset
const nextNode = {
...node,
curveOffset,
}
const handlePoint = getWallMidpointHandlePoint(nextNode)
setCursorLocalPos([handlePoint.x, 0, handlePoint.y])
useScene.getState().updateNode(nodeId, { curveOffset })
useScene.getState().markDirty(nodeId as AnyNodeId)
}
const restoreOriginal = () => {
if (previewOffsetRef.current === originalCurveOffset) {
return
}
previewOffsetRef.current = originalCurveOffset
useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset })
useScene.getState().markDirty(nodeId as AnyNodeId)
}
const onGridMove = (event: GridEvent) => {
const snapStep = getWallGridStep()
const localX = shiftPressedRef.current
? event.localPosition[0]
: snapScalarToGrid(event.localPosition[0], snapStep)
const localZ = shiftPressedRef.current
? event.localPosition[2]
: snapScalarToGrid(event.localPosition[2], snapStep)
const offsetFromMidpoint =
-(
(localX - chord.midpoint.x) * chord.normal.x +
(localZ - chord.midpoint.y) * chord.normal.y
)
const snappedOffset = shiftPressedRef.current
? offsetFromMidpoint
: snapScalarToGrid(offsetFromMidpoint, snapStep)
const nextCurveOffset = normalizeWallCurveOffset(
node,
Math.max(-maxCurveOffset, Math.min(maxCurveOffset, snappedOffset)),
)
if (
previousCurveOffsetRef.current !== null &&
nextCurveOffset !== previousCurveOffsetRef.current
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousCurveOffsetRef.current = nextCurveOffset
applyPreview(nextCurveOffset)
}
const onGridClick = (event: GridEvent) => {
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
const curveOffset = previewOffsetRef.current
wasCommitted = true
if (curveOffset !== originalCurveOffset) {
useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset })
useScene.getState().markDirty(nodeId as AnyNodeId)
resumeSceneHistory(useScene)
useScene.getState().updateNode(nodeId, { curveOffset })
useScene.getState().markDirty(nodeId as AnyNodeId)
pauseSceneHistory(useScene)
}
sfxEmitter.emit('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [nodeId] })
exitCurveMode()
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [nodeId] })
resumeSceneHistory(useScene)
markToolCancelConsumed()
exitCurveMode()
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftPressedRef.current = true
}
}
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftPressedRef.current = false
}
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
return () => {
if (!wasCommitted) {
restoreOriginal()
}
resumeSceneHistory(useScene)
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
}
}, [exitCurveMode, node])
return (
<group>
<CursorSphere position={cursorLocalPos} showTooltip={false} />
</group>
)
}
@@ -1,7 +1,9 @@
import { FenceNode, useScene, type WallNode } from '@pascal-app/core' import { FenceNode, getWallCurveFrameAt, getWallCurveLength, isCurvedWall, useScene, type WallNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import { import {
getWallAngleSnapStep,
getWallGridStep,
type WallPlanPoint, type WallPlanPoint,
findWallSnapTarget, findWallSnapTarget,
isWallLongEnough, isWallLongEnough,
@@ -58,11 +60,16 @@ function findFenceSnapTarget(
continue continue
} }
const candidates: Array<FencePlanPoint | null> = [ const candidates: Array<FencePlanPoint | null> = [fence.start, fence.end]
fence.start, if (isCurvedWall(fence)) {
fence.end, const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(fence) / 0.3))
projectPointOntoSegment(point, fence), for (let index = 0; index <= sampleCount; index += 1) {
] const frame = getWallCurveFrameAt(fence, index / sampleCount)
candidates.push([frame.point.x, frame.point.y])
}
} else {
candidates.push(projectPointOntoSegment(point, fence))
}
for (const candidate of candidates) { for (const candidate of candidates) {
if (!candidate) { if (!candidate) {
@@ -94,7 +101,12 @@ export function snapFenceDraftPoint(args: {
ignoreFenceIds?: string[] ignoreFenceIds?: string[]
}): FencePlanPoint { }): FencePlanPoint {
const { point, walls, fences, start, angleSnap = false, ignoreFenceIds } = args const { point, walls, fences, start, angleSnap = false, ignoreFenceIds } = args
const basePoint = start && angleSnap ? snapPointTo45Degrees(start, point) : snapPointToGrid(point) const gridStep = getWallGridStep()
const angleStep = getWallAngleSnapStep(gridStep)
const basePoint =
start && angleSnap
? snapPointTo45Degrees(start, point, gridStep, angleStep)
: snapPointToGrid(point, gridStep)
const fenceSnapTarget = findFenceSnapTarget(basePoint, fences, ignoreFenceIds) const fenceSnapTarget = findFenceSnapTarget(basePoint, fences, ignoreFenceIds)
return fenceSnapTarget ?? findWallSnapTarget(basePoint, walls) ?? basePoint return fenceSnapTarget ?? findWallSnapTarget(basePoint, walls) ?? basePoint
@@ -0,0 +1,327 @@
'use client'
import {
type AnyNodeId,
type FenceNode,
type WallNode,
emitter,
type GridEvent,
pauseSceneHistory,
resumeSceneHistory,
useScene,
} from '@pascal-app/core'
import { Html } from '@react-three/drei'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor, { type MovingFenceEndpoint } from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
import { snapFenceDraftPoint, type FencePlanPoint } from './fence-drafting'
import { isWallLongEnough } from '../wall/wall-drafting'
function samePoint(a: FencePlanPoint, b: FencePlanPoint) {
return a[0] === b[0] && a[1] === b[1]
}
type LinkedFenceSnapshot = {
id: FenceNode['id']
start: FencePlanPoint
end: FencePlanPoint
}
function getLinkedFenceSnapshots(args: {
fenceId: FenceNode['id']
fenceParentId: string | null
originalStart: FencePlanPoint
originalEnd: FencePlanPoint
}) {
const { fenceId, fenceParentId, originalStart, originalEnd } = args
const { nodes } = useScene.getState()
const snapshots: LinkedFenceSnapshot[] = []
for (const node of Object.values(nodes)) {
if (!(node?.type === 'fence' && node.id !== fenceId)) {
continue
}
if ((node.parentId ?? null) !== fenceParentId) {
continue
}
if (
!samePoint(node.start, originalStart) &&
!samePoint(node.start, originalEnd) &&
!samePoint(node.end, originalStart) &&
!samePoint(node.end, originalEnd)
) {
continue
}
snapshots.push({
id: node.id,
start: [...node.start] as FencePlanPoint,
end: [...node.end] as FencePlanPoint,
})
}
return snapshots
}
function getLinkedFenceUpdates(
linkedFences: LinkedFenceSnapshot[],
originalStart: FencePlanPoint,
originalEnd: FencePlanPoint,
nextStart: FencePlanPoint,
nextEnd: FencePlanPoint,
) {
return linkedFences.map((fence) => ({
id: fence.id,
start: samePoint(fence.start, originalStart)
? nextStart
: samePoint(fence.start, originalEnd)
? nextEnd
: fence.start,
end: samePoint(fence.end, originalStart)
? nextStart
: samePoint(fence.end, originalEnd)
? nextEnd
: fence.end,
}))
}
export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = ({ target }) => {
const activatedAtRef = useRef<number>(Date.now())
const previousGridPosRef = useRef<FencePlanPoint | null>(null)
const shiftPressedRef = useRef(false)
const altPressedRef = useRef(false)
const nodeIdRef = useRef(target.fence.id)
const originalStartRef = useRef<FencePlanPoint>([...target.fence.start] as FencePlanPoint)
const originalEndRef = useRef<FencePlanPoint>([...target.fence.end] as FencePlanPoint)
const fixedPointRef = useRef<FencePlanPoint>(
target.endpoint === 'start'
? ([...target.fence.end] as FencePlanPoint)
: ([...target.fence.start] as FencePlanPoint),
)
const linkedOriginalsRef = useRef(
getLinkedFenceSnapshots({
fenceId: target.fence.id,
fenceParentId: target.fence.parentId ?? null,
originalStart: target.fence.start,
originalEnd: target.fence.end,
}),
)
const previewRef = useRef<{ start: FencePlanPoint; end: FencePlanPoint } | null>(null)
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
const point = target.endpoint === 'start' ? target.fence.start : target.fence.end
return [point[0], 0, point[1]]
})
const [altPressed, setAltPressed] = useState(false)
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingFenceEndpoint(null)
}, [])
useEffect(() => {
const nodeId = nodeIdRef.current
const originalStart = originalStartRef.current
const originalEnd = originalEndRef.current
const fixedPoint = fixedPointRef.current
const siblings = Object.values(useScene.getState().nodes)
const levelWalls = siblings.filter(
(node): node is WallNode =>
node?.type === 'wall' && (node.parentId ?? null) === (target.fence.parentId ?? null),
)
const levelFences = siblings.filter(
(node): node is FenceNode =>
node?.type === 'fence' && (node.parentId ?? null) === (target.fence.parentId ?? null),
)
pauseSceneHistory(useScene)
let wasCommitted = false
const applyNodePreview = (
updates: Array<{ id: FenceNode['id']; start: FencePlanPoint; end: FencePlanPoint }>,
) => {
useScene.getState().updateNodes(
updates.map((entry) => ({
id: entry.id as AnyNodeId,
data: { start: entry.start, end: entry.end },
})),
)
for (const entry of updates) {
useScene.getState().markDirty(entry.id as AnyNodeId)
}
}
const applyPreview = (movingPoint: FencePlanPoint, detachLinkedFences = false) => {
const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint
const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint
previewRef.current = { start: nextStart, end: nextEnd }
setCursorLocalPos([movingPoint[0], 0, movingPoint[1]])
applyNodePreview([
{ id: nodeId, start: nextStart, end: nextEnd },
...(detachLinkedFences
? []
: getLinkedFenceUpdates(
linkedOriginalsRef.current,
originalStart,
originalEnd,
nextStart,
nextEnd,
)),
])
}
const restoreOriginal = () => {
applyNodePreview([
{ id: nodeId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current,
])
}
const onGridMove = (event: GridEvent) => {
const planPoint: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
const snappedPoint = snapFenceDraftPoint({
point: planPoint,
walls: levelWalls,
fences: levelFences,
start: fixedPoint,
angleSnap: !shiftPressedRef.current,
ignoreFenceIds: [nodeId],
})
if (
previousGridPosRef.current &&
(snappedPoint[0] !== previousGridPosRef.current[0] ||
snappedPoint[1] !== previousGridPosRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousGridPosRef.current = snappedPoint
applyPreview(snappedPoint, event.nativeEvent.altKey)
}
const onGridClick = (event: GridEvent) => {
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
const hasChanged =
!samePoint(preview.start, originalStart) || !samePoint(preview.end, originalEnd)
if (hasChanged && isWallLongEnough(preview.start, preview.end)) {
wasCommitted = true
applyNodePreview([
{ id: nodeId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current,
])
resumeSceneHistory(useScene)
applyNodePreview([
{ id: nodeId, start: preview.start, end: preview.end },
...(altPressedRef.current
? []
: getLinkedFenceUpdates(
linkedOriginalsRef.current,
originalStart,
originalEnd,
preview.start,
preview.end,
)),
])
pauseSceneHistory(useScene)
sfxEmitter.emit('sfx:item-place')
}
useViewer.getState().setSelection({ selectedIds: [nodeId] })
exitMoveMode()
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [nodeId] })
resumeSceneHistory(useScene)
markToolCancelConsumed()
exitMoveMode()
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return
}
if (event.key === 'Shift') {
shiftPressedRef.current = true
}
if (event.key === 'Alt') {
altPressedRef.current = true
setAltPressed(true)
}
}
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftPressedRef.current = false
}
if (event.key === 'Alt') {
altPressedRef.current = false
setAltPressed(false)
}
}
const onWindowBlur = () => {
shiftPressedRef.current = false
altPressedRef.current = false
setAltPressed(false)
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
window.addEventListener('blur', onWindowBlur)
return () => {
if (!wasCommitted) {
restoreOriginal()
}
resumeSceneHistory(useScene)
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
window.removeEventListener('blur', onWindowBlur)
}
}, [exitMoveMode, target])
return (
<group>
<CursorSphere position={cursorLocalPos} showTooltip={false} />
<Html
position={[cursorLocalPos[0], 0, cursorLocalPos[2]]}
style={{ pointerEvents: 'none', touchAction: 'none' }}
zIndexRange={[100, 0]}
>
<div className="translate-y-10">
<div
className={`whitespace-nowrap rounded-full border px-2 py-1 text-[11px] font-medium shadow-lg backdrop-blur-md transition-colors ${
altPressed
? 'border-amber-500/70 bg-amber-500/15 text-amber-100'
: 'border-border/70 bg-background/90 text-foreground/80'
}`}
>
{altPressed ? 'Detach endpoint' : 'Drag endpoint'}
</div>
</div>
</Html>
</group>
)
}
@@ -167,6 +167,14 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
const preview = previewRef.current ?? { start: originalStart, end: originalEnd } const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
wasCommitted = true wasCommitted = true
// Restore original baseline while paused so the next resume+update
// registers as a single tracked change (undo reverts to original).
applyNodePreview([
{ id: nodeId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current,
])
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
applyNodePreview([ applyNodePreview([
{ id: nodeId, start: preview.start, end: preview.end }, { id: nodeId, start: preview.start, end: preview.end },
@@ -1,7 +1,9 @@
import { import {
type AnyNodeId, type AnyNodeId,
emitter, emitter,
type FenceNode,
type GridEvent, type GridEvent,
type LevelNode,
type RoofNode, type RoofNode,
type RoofSegmentNode, type RoofSegmentNode,
type StairNode, type StairNode,
@@ -9,13 +11,16 @@ import {
sceneRegistry, sceneRegistry,
useLiveTransforms, useLiveTransforms,
useScene, useScene,
type WallNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { snapFenceDraftPoint } from '../fence/fence-drafting'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
import type { WallPlanPoint } from '../wall/wall-drafting'
export const MoveRoofTool: React.FC<{ export const MoveRoofTool: React.FC<{
node: RoofNode | RoofSegmentNode | StairNode | StairSegmentNode node: RoofNode | RoofSegmentNode | StairNode | StairSegmentNode
@@ -118,6 +123,46 @@ export const MoveRoofTool: React.FC<{
} }
} }
const resolveLevelId = () => {
if (movingNode.type === 'roof' || movingNode.type === 'stair') {
return movingNode.parentId ?? null
}
if (
(movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') &&
movingNode.parentId
) {
const parentNode = useScene.getState().nodes[movingNode.parentId as AnyNodeId]
return parentNode && 'parentId' in parentNode ? (parentNode.parentId ?? null) : null
}
return null
}
const levelId = resolveLevelId()
const levelNode =
levelId && useScene.getState().nodes[levelId as AnyNodeId]?.type === 'level'
? (useScene.getState().nodes[levelId as AnyNodeId] as LevelNode)
: null
const levelChildren = levelNode?.children ?? []
const levelWalls = levelChildren
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
.filter((node): node is WallNode => node?.type === 'wall')
const levelFences = levelChildren
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
.filter((node): node is FenceNode => node?.type === 'fence')
const buildingId = useViewer.getState().selection.buildingId
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
const localToWorldPoint = (localPoint: WallPlanPoint, y: number): [number, number, number] => {
if (buildingObj) {
const worldPoint = buildingObj.localToWorld(new THREE.Vector3(localPoint[0], y, localPoint[1]))
return [worldPoint.x, worldPoint.y, worldPoint.z]
}
return [localPoint[0], y, localPoint[1]]
}
const computeLocal = ( const computeLocal = (
gridX: number, gridX: number,
gridZ: number, gridZ: number,
@@ -155,21 +200,21 @@ export const MoveRoofTool: React.FC<{
} }
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
const gridX = Math.round(event.position[0] * 2) / 2
const gridZ = Math.round(event.position[2] * 2) / 2
const y = event.position[1] const y = event.position[1]
if ( const snappedLocal = snapFenceDraftPoint({
previousGridPosRef.current && point: [event.localPosition[0], event.localPosition[2]],
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1]) walls: levelWalls,
) { fences: levelFences,
})
const [gridX, , gridZ] = localToWorldPoint(snappedLocal, y)
if (previousGridPosRef.current && (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])) {
sfxEmitter.emit('sfx:grid-snap') sfxEmitter.emit('sfx:grid-snap')
} }
previousGridPosRef.current = [gridX, gridZ] previousGridPosRef.current = [gridX, gridZ]
// Cursor is inside the building-local ToolManager group — use local position const [lx, lz] = snappedLocal
const lx = Math.round(event.localPosition[0] * 2) / 2
const lz = Math.round(event.localPosition[2] * 2) / 2
setCursorWorldPos([lx, event.localPosition[1], lz]) setCursorWorldPos([lx, event.localPosition[1], lz])
const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz) const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz)
@@ -189,11 +234,14 @@ export const MoveRoofTool: React.FC<{
} }
const onGridClick = (event: GridEvent) => { const onGridClick = (event: GridEvent) => {
const gridX = Math.round(event.position[0] * 2) / 2 // world, for computeLocal
const gridZ = Math.round(event.position[2] * 2) / 2
const y = event.position[1] const y = event.position[1]
const lx = Math.round(event.localPosition[0] * 2) / 2 const snappedLocal = snapFenceDraftPoint({
const lz = Math.round(event.localPosition[2] * 2) / 2 point: [event.localPosition[0], event.localPosition[2]],
walls: levelWalls,
fences: levelFences,
})
const [gridX, , gridZ] = localToWorldPoint(snappedLocal, y)
const [lx, lz] = snappedLocal
const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz) const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz)
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, Float32BufferAttribute, type Line, type Object3D } from 'three' import { BufferGeometry, Float32BufferAttribute, type Line, type Object3D } from 'three'
import { EDITOR_LAYER } from '../../../lib/constants' import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import { snapToHalf } from '../item/placement-math'
const Y_OFFSET = 0.02 const Y_OFFSET = 0.02
@@ -187,8 +188,8 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
// Listen to grid:move events to track cursor position // Listen to grid:move events to track cursor position
useEffect(() => { useEffect(() => {
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
const gridX = Math.round(event.localPosition[0] * 2) / 2 const gridX = snapToHalf(event.localPosition[0])
const gridZ = Math.round(event.localPosition[2] * 2) / 2 const gridZ = snapToHalf(event.localPosition[2])
const newPosition: [number, number] = [gridX, gridZ] const newPosition: [number, number] = [gridX, gridZ]
// Play snap sound when cursor moves to a new grid cell during drag // Play snap sound when cursor moves to a new grid cell during drag
@@ -1,17 +1,23 @@
'use client' 'use client'
import { type AnyNodeId, emitter, type GridEvent, useScene, type SlabNode } from '@pascal-app/core' import {
type AnyNodeId,
emitter,
type FenceNode,
type GridEvent,
type LevelNode,
type SlabNode,
useScene,
type WallNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { snapFenceDraftPoint } from '../fence/fence-drafting'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
function snap(value: number) {
return Math.round(value * 2) / 2
}
function translatePolygon( function translatePolygon(
polygon: Array<[number, number]>, polygon: Array<[number, number]>,
deltaX: number, deltaX: number,
@@ -56,6 +62,17 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
useEffect(() => { useEffect(() => {
const originalPolygon = originalPolygonRef.current const originalPolygon = originalPolygonRef.current
const originalHoles = originalHolesRef.current const originalHoles = originalHolesRef.current
const levelNode =
node.parentId && useScene.getState().nodes[node.parentId as AnyNodeId]?.type === 'level'
? (useScene.getState().nodes[node.parentId as AnyNodeId] as LevelNode)
: null
const levelChildren = levelNode?.children ?? []
const levelWalls = levelChildren
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
.filter((child): child is WallNode => child?.type === 'wall')
const levelFences = levelChildren
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
.filter((child): child is FenceNode => child?.type === 'fence')
useScene.temporal.getState().pause() useScene.temporal.getState().pause()
let wasCommitted = false let wasCommitted = false
@@ -80,8 +97,11 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
} }
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
const localX = snap(event.localPosition[0]) const [localX, localZ] = snapFenceDraftPoint({
const localZ = snap(event.localPosition[2]) point: [event.localPosition[0], event.localPosition[2]],
walls: levelWalls,
fences: levelFences,
})
if ( if (
previousGridPosRef.current && previousGridPosRef.current &&
@@ -11,7 +11,9 @@ import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor'
import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor' import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor'
import { CeilingTool } from './ceiling/ceiling-tool' import { CeilingTool } from './ceiling/ceiling-tool'
import { DoorTool } from './door/door-tool' import { DoorTool } from './door/door-tool'
import { CurveFenceTool } from './fence/curve-fence-tool'
import { FenceTool } from './fence/fence-tool' import { FenceTool } from './fence/fence-tool'
import { MoveFenceEndpointTool } from './fence/move-fence-endpoint-tool'
import { ItemTool } from './item/item-tool' import { ItemTool } from './item/item-tool'
import { MoveTool } from './item/move-tool' import { MoveTool } from './item/move-tool'
import { RoofTool } from './roof/roof-tool' import { RoofTool } from './roof/roof-tool'
@@ -54,7 +56,9 @@ export const ToolManager: React.FC = () => {
const tool = useEditor((state) => state.tool) const tool = useEditor((state) => state.tool)
const movingNode = useEditor((state) => state.movingNode) const movingNode = useEditor((state) => state.movingNode)
const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint) const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint)
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
const curvingWall = useEditor((state) => state.curvingWall) const curvingWall = useEditor((state) => state.curvingWall)
const curvingFence = useEditor((state) => state.curvingFence)
const editingHole = useEditor((state) => state.editingHole) const editingHole = useEditor((state) => state.editingHole)
const selectedZoneId = useViewer((state) => state.selection.zoneId) const selectedZoneId = useViewer((state) => state.selection.zoneId)
const buildingId = useViewer((state) => state.selection.buildingId) const buildingId = useViewer((state) => state.selection.buildingId)
@@ -145,7 +149,9 @@ export const ToolManager: React.FC = () => {
<CeilingHoleEditor ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} /> <CeilingHoleEditor ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} />
)} )}
{movingWallEndpoint && <MoveWallEndpointTool target={movingWallEndpoint} />} {movingWallEndpoint && <MoveWallEndpointTool target={movingWallEndpoint} />}
{movingFenceEndpoint && <MoveFenceEndpointTool target={movingFenceEndpoint} />}
{curvingWall && <CurveWallTool node={curvingWall} />} {curvingWall && <CurveWallTool node={curvingWall} />}
{curvingFence && <CurveFenceTool node={curvingFence} />}
{movingNode && movingNode.type !== 'building' && <MoveTool />} {movingNode && movingNode.type !== 'building' && <MoveTool />}
{!movingNode && BuildToolComponent && <BuildToolComponent />} {!movingNode && BuildToolComponent && <BuildToolComponent />}
</group> </group>
@@ -1,6 +1,14 @@
'use client' 'use client'
import { type AnyNodeId, emitter, type GridEvent, useScene, type WallNode } from '@pascal-app/core' import {
type AnyNodeId,
emitter,
type GridEvent,
pauseSceneHistory,
resumeSceneHistory,
useScene,
type WallNode,
} from '@pascal-app/core'
import { Html } from '@react-three/drei' import { Html } from '@react-three/drei'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
@@ -127,7 +135,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
node?.type === 'wall' && (node.parentId ?? null) === (target.wall.parentId ?? null), node?.type === 'wall' && (node.parentId ?? null) === (target.wall.parentId ?? null),
) )
useScene.temporal.getState().pause() pauseSceneHistory(useScene)
let wasCommitted = false let wasCommitted = false
const applyNodePreview = ( const applyNodePreview = (
@@ -209,7 +217,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
...linkedOriginalsRef.current, ...linkedOriginalsRef.current,
]) ])
useScene.temporal.getState().resume() resumeSceneHistory(useScene)
applyNodePreview([ applyNodePreview([
{ id: nodeId, start: preview.start, end: preview.end }, { id: nodeId, start: preview.start, end: preview.end },
...(altPressedRef.current ...(altPressedRef.current
@@ -222,7 +230,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
preview.end, preview.end,
)), )),
]) ])
useScene.temporal.getState().pause() pauseSceneHistory(useScene)
sfxEmitter.emit('sfx:item-place') sfxEmitter.emit('sfx:item-place')
} }
@@ -234,7 +242,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
const onCancel = () => { const onCancel = () => {
restoreOriginal() restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [nodeId] }) useViewer.getState().setSelection({ selectedIds: [nodeId] })
useScene.temporal.getState().resume() resumeSceneHistory(useScene)
markToolCancelConsumed() markToolCancelConsumed()
exitMoveMode() exitMoveMode()
} }
@@ -279,7 +287,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
if (!wasCommitted) { if (!wasCommitted) {
restoreOriginal() restoreOriginal()
} }
useScene.temporal.getState().resume() resumeSceneHistory(useScene)
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick) emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel) emitter.off('tool:cancel', onCancel)
@@ -1,6 +1,14 @@
'use client' 'use client'
import { type AnyNodeId, emitter, type GridEvent, useScene, type WallNode } from '@pascal-app/core' import {
type AnyNodeId,
emitter,
type GridEvent,
pauseSceneHistory,
resumeSceneHistory,
useScene,
type WallNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
@@ -24,9 +32,9 @@ function stripWallIsNewMetadata(meta: WallNode['metadata']): WallNode['metadata'
return meta return meta
} }
const nextMeta = { ...(meta as Record<string, unknown>) } const nextMeta = { ...(meta as Record<string, unknown>) } as Record<string, unknown>
delete nextMeta.isNew delete nextMeta.isNew
return nextMeta return nextMeta as WallNode['metadata']
} }
type LinkedWallSnapshot = { type LinkedWallSnapshot = {
@@ -146,7 +154,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
const originalCenter = originalCenterRef.current const originalCenter = originalCenterRef.current
const originalHalfVector = originalHalfVectorRef.current const originalHalfVector = originalHalfVectorRef.current
useScene.temporal.getState().pause() pauseSceneHistory(useScene)
let wasCommitted = false let wasCommitted = false
const applyNodePreview = ( const applyNodePreview = (
@@ -237,7 +245,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
...linkedOriginalsRef.current, ...linkedOriginalsRef.current,
]) ])
useScene.temporal.getState().resume() resumeSceneHistory(useScene)
const commitUpdates = [ const commitUpdates = [
{ {
@@ -266,7 +274,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
useScene.getState().markDirty(id) useScene.getState().markDirty(id)
} }
useScene.temporal.getState().pause() pauseSceneHistory(useScene)
sfxEmitter.emit('sfx:item-place') sfxEmitter.emit('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [nodeId] }) useViewer.getState().setSelection({ selectedIds: [nodeId] })
@@ -315,7 +323,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
const onCancel = () => { const onCancel = () => {
restoreOriginal() restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [nodeId] }) useViewer.getState().setSelection({ selectedIds: [nodeId] })
useScene.temporal.getState().resume() resumeSceneHistory(useScene)
markToolCancelConsumed() markToolCancelConsumed()
exitMoveMode() exitMoveMode()
} }
@@ -331,7 +339,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
restoreOriginal() restoreOriginal()
} }
shiftPressedRef.current = false shiftPressedRef.current = false
useScene.temporal.getState().resume() resumeSceneHistory(useScene)
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick) emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel) emitter.off('tool:cancel', onCancel)
@@ -9,7 +9,15 @@ import { cn } from './../../../lib/utils'
import useEditor from './../../../store/use-editor' import useEditor from './../../../store/use-editor'
import { ActionButton } from './action-button' import { ActionButton } from './action-button'
type ControlId = 'select' | 'box-select' | 'site-edit' | 'build' | 'furnish' | 'zone' | 'delete' type ControlId =
| 'select'
| 'box-select'
| 'site-edit'
| 'build'
| 'material-paint'
| 'furnish'
| 'zone'
| 'delete'
type ControlConfig = { type ControlConfig = {
id: ControlId id: ControlId
@@ -54,6 +62,14 @@ const controls: ControlConfig[] = [
color: 'hover:bg-green-500/20 hover:text-green-400', color: 'hover:bg-green-500/20 hover:text-green-400',
activeColor: 'bg-green-500/20 text-green-400', activeColor: 'bg-green-500/20 text-green-400',
}, },
{
id: 'material-paint',
imageSrc: '/icons/paint.png',
label: 'Material Paint',
shortcut: 'P',
color: 'hover:bg-amber-500/20 hover:text-amber-400',
activeColor: 'bg-amber-500/20 text-amber-400',
},
{ {
id: 'furnish', id: 'furnish',
imageSrc: '/icons/couch.png', imageSrc: '/icons/couch.png',
@@ -88,6 +104,7 @@ export function ControlModes() {
const setPhase = useEditor((state) => state.setPhase) const setPhase = useEditor((state) => state.setPhase)
const setStructureLayer = useEditor((state) => state.setStructureLayer) const setStructureLayer = useEditor((state) => state.setStructureLayer)
const setSelectionTool = useEditor((state) => state.setFloorplanSelectionTool) const setSelectionTool = useEditor((state) => state.setFloorplanSelectionTool)
const primeMaterialPaintFromSelection = useEditor((state) => state.primeMaterialPaintFromSelection)
const levelId = useViewer((s) => s.selection.levelId) const levelId = useViewer((s) => s.selection.levelId)
// Only subscribe to the primitive `level` number — when walls are added to // Only subscribe to the primitive `level` number — when walls are added to
@@ -112,6 +129,7 @@ export function ControlModes() {
if (id === 'site-edit') return false if (id === 'site-edit') return false
if (id === 'build') if (id === 'build')
return mode === 'build' && phase === 'structure' && structureLayer === 'elements' return mode === 'build' && phase === 'structure' && structureLayer === 'elements'
if (id === 'material-paint') return mode === 'material-paint'
if (id === 'furnish') return mode === 'build' && phase === 'furnish' if (id === 'furnish') return mode === 'build' && phase === 'furnish'
if (id === 'zone') if (id === 'zone')
return mode === 'build' && phase === 'structure' && structureLayer === 'zones' return mode === 'build' && phase === 'structure' && structureLayer === 'zones'
@@ -155,6 +173,15 @@ export function ControlModes() {
setStructureLayer('elements') setStructureLayer('elements')
setMode('build') setMode('build')
} }
} else if (id === 'material-paint') {
if (getIsActive('material-paint')) {
setMode('select')
} else {
primeMaterialPaintFromSelection()
setPhase('structure')
setStructureLayer('elements')
setMode('material-paint')
}
} else if (id === 'furnish') { } else if (id === 'furnish') {
if (getIsActive('furnish')) { if (getIsActive('furnish')) {
setMode('select') setMode('select')
@@ -1,8 +1,13 @@
'use client' 'use client'
import { useScene } from '@pascal-app/core'
import { AnimatePresence, motion } from 'motion/react' import { AnimatePresence, motion } from 'motion/react'
import { useEffect, useMemo } from 'react'
import { useViewer } from '@pascal-app/viewer'
import { TooltipProvider } from './../../../components/ui/primitives/tooltip' import { TooltipProvider } from './../../../components/ui/primitives/tooltip'
import { MaterialPicker } from './../../../components/ui/controls/material-picker'
import { useReducedMotion } from './../../../hooks/use-reduced-motion' import { useReducedMotion } from './../../../hooks/use-reduced-motion'
import { resolvePaintTargetFromSelection } from './../../../lib/material-paint'
import { cn } from './../../../lib/utils' import { cn } from './../../../lib/utils'
import useEditor from './../../../store/use-editor' import useEditor from './../../../store/use-editor'
import { ItemCatalog } from '../item-catalog/item-catalog' import { ItemCatalog } from '../item-catalog/item-catalog'
@@ -12,12 +17,49 @@ import { FurnishTools } from './furnish-tools'
import { StructureTools } from './structure-tools' import { StructureTools } from './structure-tools'
import { ViewToggles } from './view-toggles' import { ViewToggles } from './view-toggles'
function PaintMaterialTray() {
const activePaintMaterial = useEditor((state) => state.activePaintMaterial)
const activePaintTarget = useEditor((state) => state.activePaintTarget)
const setActivePaintMaterial = useEditor((state) => state.setActivePaintMaterial)
const setActivePaintTarget = useEditor((state) => state.setActivePaintTarget)
const selectedIds = useViewer((state) => state.selection.selectedIds)
const nodes = useScene((state) => state.nodes)
const selectedId = selectedIds.length === 1 ? (selectedIds[0] ?? null) : null
useEffect(() => {
const selectedPaintTarget = resolvePaintTargetFromSelection({
nodes,
selectedId,
})
if (selectedPaintTarget) {
setActivePaintTarget(selectedPaintTarget)
}
}, [nodes, selectedId, setActivePaintTarget])
return (
<div className="w-[42rem] max-w-[calc(100vw-2rem)]">
<MaterialPicker
onChange={(material) => {
setActivePaintMaterial({ material, sourceTarget: activePaintTarget })
}}
onSelectMaterialPreset={(materialPreset) => {
setActivePaintMaterial({ materialPreset, sourceTarget: activePaintTarget })
}}
selectedMaterialPreset={activePaintMaterial?.materialPreset}
value={activePaintMaterial?.material}
/>
</div>
)
}
export function ActionMenu({ className }: { className?: string }) { export function ActionMenu({ className }: { className?: string }) {
const phase = useEditor((state) => state.phase) const phase = useEditor((state) => state.phase)
const mode = useEditor((state) => state.mode) const mode = useEditor((state) => state.mode)
const tool = useEditor((state) => state.tool) const tool = useEditor((state) => state.tool)
const catalogCategory = useEditor((state) => state.catalogCategory) const catalogCategory = useEditor((state) => state.catalogCategory)
const reducedMotion = useReducedMotion() const reducedMotion = useReducedMotion()
const showPaintTray = useMemo(() => mode === 'material-paint', [mode])
const transition = reducedMotion const transition = reducedMotion
? { duration: 0 } ? { duration: 0 }
: { type: 'spring' as const, bounce: 0.2, duration: 0.4 } : { type: 'spring' as const, bounce: 0.2, duration: 0.4 }
@@ -138,6 +180,38 @@ export function ActionMenu({ className }: { className?: string }) {
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
<AnimatePresence>
{showPaintTray && (
<motion.div
animate={{
opacity: 1,
maxHeight: 96,
paddingTop: 8,
paddingBottom: 8,
borderBottomWidth: 1,
}}
className={cn('overflow-hidden border-border border-b px-3')}
exit={{
opacity: 0,
maxHeight: 0,
paddingTop: 0,
paddingBottom: 0,
borderBottomWidth: 0,
}}
initial={{
opacity: 0,
maxHeight: 0,
paddingTop: 0,
paddingBottom: 0,
borderBottomWidth: 0,
}}
transition={transition}
>
<PaintMaterialTray />
</motion.div>
)}
</AnimatePresence>
{/* Control Mode Row - Always visible, centered */} {/* Control Mode Row - Always visible, centered */}
<div className="flex items-center justify-center gap-1 px-2 py-1.5"> <div className="flex items-center justify-center gap-1 px-2 py-1.5">
<ControlModes /> <ControlModes />
@@ -23,6 +23,7 @@ import {
Moon, Moon,
MousePointer2, MousePointer2,
Package, Package,
PaintBucket,
PencilLine, PencilLine,
Plus, Plus,
Redo2, Redo2,
@@ -35,6 +36,7 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { useEffect } from 'react' import { useEffect } from 'react'
import { deleteLevelWithFallbackSelection } from '../../../lib/level-selection' import { deleteLevelWithFallbackSelection } from '../../../lib/level-selection'
import { runRedo, runUndo } from '../../../lib/history'
import { useCommandRegistry } from '../../../store/use-command-registry' import { useCommandRegistry } from '../../../store/use-command-registry'
import type { StructureTool } from '../../../store/use-editor' import type { StructureTool } from '../../../store/use-editor'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
@@ -48,6 +50,7 @@ export function EditorCommands() {
const setMode = useEditor((s) => s.setMode) const setMode = useEditor((s) => s.setMode)
const setTool = useEditor((s) => s.setTool) const setTool = useEditor((s) => s.setTool)
const setStructureLayer = useEditor((s) => s.setStructureLayer) const setStructureLayer = useEditor((s) => s.setStructureLayer)
const primeMaterialPaintFromSelection = useEditor((s) => s.primeMaterialPaintFromSelection)
const isPreviewMode = useEditor((s) => s.isPreviewMode) const isPreviewMode = useEditor((s) => s.isPreviewMode)
const setPreviewMode = useEditor((s) => s.setPreviewMode) const setPreviewMode = useEditor((s) => s.setPreviewMode)
@@ -149,6 +152,21 @@ export function EditorCommands() {
useScene.getState().deleteNodes(selectedIds as any[]) useScene.getState().deleteNodes(selectedIds as any[])
}), }),
}, },
{
id: 'editor.mode.material-paint',
label: 'Material Paint',
group: 'Scene',
icon: <PaintBucket className="h-4 w-4" />,
keywords: ['paint', 'material', 'texture', 'bucket', 'surface'],
shortcut: ['P'],
execute: () =>
run(() => {
primeMaterialPaintFromSelection()
setPhase('structure')
setStructureLayer('elements')
setMode('material-paint')
}),
},
// ── Levels ─────────────────────────────────────────────────────────── // ── Levels ───────────────────────────────────────────────────────────
{ {
@@ -313,7 +331,7 @@ export function EditorCommands() {
group: 'History', group: 'History',
icon: <Undo2 className="h-4 w-4" />, icon: <Undo2 className="h-4 w-4" />,
keywords: ['undo', 'revert', 'back'], keywords: ['undo', 'revert', 'back'],
execute: () => run(() => useScene.temporal.getState().undo()), execute: () => run(() => runUndo()),
}, },
{ {
id: 'editor.history.redo', id: 'editor.history.redo',
@@ -321,7 +339,7 @@ export function EditorCommands() {
group: 'History', group: 'History',
icon: <Redo2 className="h-4 w-4" />, icon: <Redo2 className="h-4 w-4" />,
keywords: ['redo', 'forward', 'repeat'], keywords: ['redo', 'forward', 'repeat'],
execute: () => run(() => useScene.temporal.getState().redo()), execute: () => run(() => runRedo()),
}, },
// ── Export & Share ─────────────────────────────────────────────────── // ── Export & Share ───────────────────────────────────────────────────
@@ -354,7 +372,7 @@ export function EditorCommands() {
icon: <Box className="h-4 w-4" />, icon: <Box className="h-4 w-4" />,
keywords: ['export', 'glb', 'gltf', '3d', 'model', 'download'], keywords: ['export', 'glb', 'gltf', '3d', 'model', 'download'],
execute: () => run(() => exportScene()), execute: () => run(() => exportScene()),
} as const, },
] ]
: []), : []),
{ {
@@ -1,49 +1,117 @@
'use client' 'use client'
import { import {
getMaterialsForTarget, getCatalogMaterialById,
getLibraryMaterialIdFromRef,
getMaterialsForCategory,
MATERIAL_CATEGORIES,
toLibraryMaterialRef, toLibraryMaterialRef,
type MaterialSchema, type MaterialSchema,
type MaterialTarget,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useState } from 'react' import { useEffect, useRef, useState } from 'react'
import useEditor from '../../../store/use-editor'
type MaterialPickerProps = { type MaterialPickerProps = {
nodeType?: MaterialTarget
value?: MaterialSchema value?: MaterialSchema
selectedMaterialPreset?: string selectedMaterialPreset?: string
onChange?: (material: MaterialSchema) => void onChange?: (material: MaterialSchema) => void
onSelectMaterialPreset?: (materialPreset: string) => void onSelectMaterialPreset?: (materialPreset: string) => void
disabled?: boolean
} }
export function MaterialPicker({ export function MaterialPicker({
nodeType,
value, value,
selectedMaterialPreset, selectedMaterialPreset,
onChange, onChange,
onSelectMaterialPreset, onSelectMaterialPreset,
disabled = false,
}: MaterialPickerProps) { }: MaterialPickerProps) {
const setPaintPanelOpen = useEditor((state) => state.setPaintPanelOpen)
const [showCustom, setShowCustom] = useState<boolean>(!!value?.properties) const [showCustom, setShowCustom] = useState<boolean>(!!value?.properties)
const catalogItems = nodeType ? getMaterialsForTarget(nodeType) : [] const [selectedCategory, setSelectedCategory] = useState<(typeof MATERIAL_CATEGORIES)[number]>(
MATERIAL_CATEGORIES[0],
)
const catalogScrollRef = useRef<HTMLDivElement>(null)
const categoryScrollRef = useRef<HTMLDivElement>(null)
const catalogItems =
selectedCategory === 'other'
? getMaterialsForCategory('other')
: getMaterialsForCategory(selectedCategory)
useEffect(() => {
setShowCustom(!!value?.properties && !selectedMaterialPreset)
}, [selectedMaterialPreset, value?.properties])
useEffect(() => {
if (!selectedMaterialPreset && value?.properties) {
setSelectedCategory('other')
return
}
const catalogId =
getLibraryMaterialIdFromRef(selectedMaterialPreset) ?? value?.id ?? undefined
const selectedCatalogEntry = getCatalogMaterialById(catalogId)
if (selectedCatalogEntry?.category) {
setSelectedCategory(selectedCatalogEntry.category)
}
}, [selectedMaterialPreset, value?.id])
const currentProps = value?.properties || {
color: '#ffffff',
roughness: 0.5,
metalness: 0,
opacity: 1,
transparent: false,
side: 'front' as const,
}
const selectedCatalogId = const selectedCatalogId =
selectedMaterialPreset ?? (value?.id ? toLibraryMaterialRef(value.id) : undefined) selectedMaterialPreset ?? (value?.id ? toLibraryMaterialRef(value.id) : undefined)
const handleCatalogSelect = (materialId: string) => { const handleCatalogSelect = (materialId: string) => {
if (disabled) return
setShowCustom(false) setShowCustom(false)
setPaintPanelOpen(false)
onSelectMaterialPreset?.(toLibraryMaterialRef(materialId)) onSelectMaterialPreset?.(toLibraryMaterialRef(materialId))
} }
useEffect(() => {
const container = catalogScrollRef.current
if (!container) return
const handleWheel = (event: WheelEvent) => {
const deltaX = event.deltaX
const deltaY = event.deltaY
const nextScrollLeft = container.scrollLeft + deltaX + deltaY
if (nextScrollLeft === container.scrollLeft) return
event.preventDefault()
container.scrollLeft = nextScrollLeft
}
container.addEventListener('wheel', handleWheel, { passive: false })
return () => {
container.removeEventListener('wheel', handleWheel)
}
}, [catalogItems.length, onChange, showCustom])
useEffect(() => {
const container = categoryScrollRef.current
if (!container) return
const handleWheel = (event: WheelEvent) => {
const deltaX = event.deltaX
const deltaY = event.deltaY
const nextScrollLeft = container.scrollLeft + deltaX + deltaY
if (nextScrollLeft === container.scrollLeft) return
event.preventDefault()
container.scrollLeft = nextScrollLeft
}
container.addEventListener('wheel', handleWheel, { passive: false })
return () => {
container.removeEventListener('wheel', handleWheel)
}
}, [])
const handleCustomOpen = () => { const handleCustomOpen = () => {
if (disabled) return
setShowCustom(true) setShowCustom(true)
setPaintPanelOpen(true)
onChange?.({ onChange?.({
preset: 'custom', preset: 'custom',
properties: { properties: {
@@ -57,155 +125,87 @@ export function MaterialPicker({
}) })
} }
const handlePropertyChange = (
prop: keyof typeof currentProps,
val: (typeof currentProps)[keyof typeof currentProps],
) => {
onChange?.({
preset: 'custom',
properties: {
...currentProps,
[prop]: val,
},
})
}
return ( return (
<div className="space-y-3"> <div className={`min-w-0 space-y-3 ${disabled ? 'pointer-events-none opacity-50' : ''}`}>
{(catalogItems.length > 0 || onChange) && ( {(catalogItems.length > 0 || onChange) && (
<div className="space-y-2"> <div className="min-w-0 space-y-1">
{catalogItems.length > 0 ? ( <div
<div className="text-gray-500 text-xs uppercase tracking-[0.16em]">Library</div> className="w-full max-w-full overflow-x-auto overflow-y-hidden"
) : null} ref={categoryScrollRef}
<div className="flex flex-wrap gap-1.5"> style={{ msOverflowStyle: 'none', scrollbarWidth: 'none' }}
{catalogItems.map((item) => ( >
<button <div className="flex min-w-max gap-1 pb-1">
className={`h-14 w-14 shrink-0 overflow-hidden rounded-lg border transition-all ${ {MATERIAL_CATEGORIES.map((category) => (
selectedCatalogId === toLibraryMaterialRef(item.id) <button
? 'border-blue-500 ring-2 ring-blue-500/30' className={`shrink-0 px-2 font-medium text-[11px] uppercase tracking-[0.12em] transition-all ${
: 'border-gray-300 hover:border-gray-400' selectedCategory === category
}`} ? 'bg-transparent text-foreground'
key={item.id} : 'bg-transparent text-muted-foreground opacity-70 hover:text-foreground hover:opacity-100'
onClick={() => handleCatalogSelect(item.id)} }`}
title={item.label} key={category}
type="button" onClick={() => {
> setSelectedCategory(category)
{item.previewThumbnailUrl ? ( if (showCustom) {
<img setShowCustom(false)
alt={item.label} }
className="h-full w-full object-cover" if (category !== 'other') {
src={item.previewThumbnailUrl} setPaintPanelOpen(false)
/> }
) : item.previewColor ? ( }}
<div className="h-full w-full" style={{ backgroundColor: item.previewColor }} /> type="button"
) : ( >
<div className="h-full w-full bg-gray-100" /> {category.charAt(0).toUpperCase() + category.slice(1)}
)} </button>
</button> ))}
))} </div>
{onChange ? (
<button
className={`flex h-14 w-14 shrink-0 items-center justify-center rounded-lg border text-[10px] font-medium transition-all ${
showCustom
? 'border-blue-500 bg-blue-50 text-blue-700 ring-2 ring-blue-500/30'
: 'border-gray-300 bg-white text-gray-500 hover:border-gray-400'
}`}
onClick={handleCustomOpen}
title="Custom"
type="button"
>
Custom
</button>
) : null}
</div> </div>
</div> <div
)} className="w-full max-w-full overflow-x-auto overflow-y-hidden"
ref={catalogScrollRef}
{showCustom && onChange && ( style={{ msOverflowStyle: 'none', scrollbarWidth: 'none' }}
<div className="space-y-2 pt-2"> >
<div className="flex items-center gap-2"> <div className="flex min-w-max gap-1.5 pb-1">
<label className="w-16 text-gray-500 text-xs">Color</label> {catalogItems.map((item) => (
<input <button
className="h-7 w-12 cursor-pointer rounded border border-gray-300" className={`relative h-14 w-14 shrink-0 overflow-hidden rounded-lg border transition-all ${
onChange={(e) => handlePropertyChange('color', e.target.value)} selectedCatalogId === toLibraryMaterialRef(item.id)
type="color" ? 'border-blue-500 ring-2 ring-blue-500/30'
value={currentProps.color} : 'border-gray-300 hover:border-gray-400'
/> }`}
<input key={item.id}
className="h-7 flex-1 rounded border border-gray-300 px-2 text-xs" onClick={() => handleCatalogSelect(item.id)}
onChange={(e) => handlePropertyChange('color', e.target.value)} title={item.label}
type="text" type="button"
value={currentProps.color} >
/> <div className="pointer-events-none absolute inset-0 rounded-[inherit] ring-1 ring-inset ring-white/12" />
</div> {item.previewThumbnailUrl ? (
<img
<div className="flex items-center gap-2"> alt={item.label}
<label className="w-16 text-gray-500 text-xs">Roughness</label> className="h-full w-full object-cover"
<input src={item.previewThumbnailUrl}
className="h-1.5 flex-1 cursor-pointer appearance-none rounded-lg bg-gray-200" />
max={1} ) : item.previewColor ? (
min={0} <div className="h-full w-full" style={{ backgroundColor: item.previewColor }} />
onChange={(e) => handlePropertyChange('roughness', Number.parseFloat(e.target.value))} ) : (
step={0.01} <div className="h-full w-full bg-gray-100" />
type="range" )}
value={currentProps.roughness} </button>
/> ))}
<span className="w-8 text-right text-gray-400 text-xs"> {selectedCategory === 'other' && onChange ? (
{currentProps.roughness.toFixed(2)} <button
</span> className={`flex h-14 w-14 shrink-0 items-center justify-center rounded-lg border text-[10px] font-medium transition-all ${
</div> showCustom
? 'border-blue-500 ring-2 ring-blue-500/30'
<div className="flex items-center gap-2"> : 'border-gray-300 hover:border-gray-400'
<label className="w-16 text-gray-500 text-xs">Metalness</label> }`}
<input onClick={handleCustomOpen}
className="h-1.5 flex-1 cursor-pointer appearance-none rounded-lg bg-gray-200" title="Custom"
max={1} type="button"
min={0} >
onChange={(e) => handlePropertyChange('metalness', Number.parseFloat(e.target.value))} Custom
step={0.01} </button>
type="range" ) : null}
value={currentProps.metalness} </div>
/>
<span className="w-8 text-right text-gray-400 text-xs">
{currentProps.metalness.toFixed(2)}
</span>
</div>
<div className="flex items-center gap-2">
<label className="w-16 text-gray-500 text-xs">Opacity</label>
<input
className="h-1.5 flex-1 cursor-pointer appearance-none rounded-lg bg-gray-200"
max={1}
min={0}
onChange={(e) => {
const opacity = Number.parseFloat(e.target.value)
handlePropertyChange('opacity', opacity)
if (opacity < 1 && !currentProps.transparent) {
handlePropertyChange('transparent', true)
}
}}
step={0.01}
type="range"
value={currentProps.opacity}
/>
<span className="w-8 text-right text-gray-400 text-xs">
{currentProps.opacity.toFixed(2)}
</span>
</div>
<div className="flex items-center gap-2">
<label className="w-16 text-gray-500 text-xs">Side</label>
<select
className="h-7 flex-1 rounded border border-gray-300 px-2 text-xs"
onChange={(e) =>
handlePropertyChange('side', e.target.value as 'front' | 'back' | 'double')
}
value={currentProps.side}
>
<option value="front">Front</option>
<option value="back">Back</option>
<option value="double">Double</option>
</select>
</div> </div>
</div> </div>
)} )}
@@ -21,6 +21,20 @@ function stepPrecision(s: number): number {
return Math.max(0, Math.ceil(-Math.log10(s))) return Math.max(0, Math.ceil(-Math.log10(s)))
} }
function getAdjustedStep(
baseStep: number,
modifiers: {
shiftKey?: boolean
metaKey?: boolean
ctrlKey?: boolean
altKey?: boolean
},
): number {
if (modifiers.shiftKey) return baseStep * 10
if (modifiers.metaKey || modifiers.ctrlKey || modifiers.altKey) return baseStep * 0.1
return baseStep
}
export function SliderControl({ export function SliderControl({
label, label,
value, value,
@@ -58,16 +72,14 @@ export function SliderControl({
if (isEditing) return if (isEditing) return
e.preventDefault() e.preventDefault()
const direction = e.deltaY < 0 ? 1 : -1 const direction = e.deltaY < 0 ? 1 : -1
let s = step const s = getAdjustedStep(step, e)
if (e.shiftKey) s = step * 10
else if (e.altKey) s = step * 0.1
const newValue = clamp(valueRef.current + direction * s) const newValue = clamp(valueRef.current + direction * s)
const final = Number.parseFloat(newValue.toFixed(stepPrecision(s))) const final = Number.parseFloat(newValue.toFixed(stepPrecision(s)))
if (final !== valueRef.current) onChange(final) if (final !== valueRef.current) onChange(final)
} }
el.addEventListener('wheel', handleWheel, { passive: false }) el.addEventListener('wheel', handleWheel, { passive: false })
return () => el.removeEventListener('wheel', handleWheel) return () => el.removeEventListener('wheel', handleWheel)
}, [isEditing, step, clamp, onChange, precision]) }, [isEditing, step, clamp, onChange])
// Arrow key support while hovered // Arrow key support while hovered
useEffect(() => { useEffect(() => {
@@ -78,9 +90,7 @@ export function SliderControl({
else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') direction = -1 else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') direction = -1
if (direction !== 0) { if (direction !== 0) {
e.preventDefault() e.preventDefault()
let s = step const s = getAdjustedStep(step, e)
if (e.shiftKey) s = step * 10
else if (e.metaKey || e.ctrlKey) s = step * 0.1
const newValue = clamp(valueRef.current + direction * s) const newValue = clamp(valueRef.current + direction * s)
const final = Number.parseFloat(newValue.toFixed(stepPrecision(s))) const final = Number.parseFloat(newValue.toFixed(stepPrecision(s)))
if (final !== valueRef.current) onChange(final) if (final !== valueRef.current) onChange(final)
@@ -88,7 +98,7 @@ export function SliderControl({
} }
window.addEventListener('keydown', handleKeyDown) window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown)
}, [isHovered, isEditing, step, clamp, onChange, precision]) }, [isHovered, isEditing, step, clamp, onChange])
const handleLabelPointerDown = useCallback( const handleLabelPointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => { (e: React.PointerEvent<HTMLDivElement>) => {
@@ -107,16 +117,14 @@ export function SliderControl({
if (!dragRef.current) return if (!dragRef.current) return
const { startX, startValue } = dragRef.current const { startX, startValue } = dragRef.current
const dx = e.clientX - startX const dx = e.clientX - startX
let s = step const s = getAdjustedStep(step, e)
if (e.shiftKey) s = step * 10
else if (e.metaKey || e.ctrlKey) s = step * 0.1
// 4 px per step at default sensitivity // 4 px per step at default sensitivity
const newValue = clamp( const newValue = clamp(
Number.parseFloat((startValue + (dx / 4) * s).toFixed(stepPrecision(s))), Number.parseFloat((startValue + (dx / 4) * s).toFixed(stepPrecision(s))),
) )
onChange(newValue) onChange(newValue)
}, },
[step, precision, clamp, onChange], [step, clamp, onChange],
) )
const handleLabelPointerUp = useCallback( const handleLabelPointerUp = useCallback(
@@ -163,12 +171,18 @@ export function SliderControl({
setIsEditing(false) setIsEditing(false)
} else if (e.key === 'ArrowUp') { } else if (e.key === 'ArrowUp') {
e.preventDefault() e.preventDefault()
const newV = clamp(value + step) const adjustedStep = getAdjustedStep(step, e)
const newV = clamp(
Number.parseFloat((value + adjustedStep).toFixed(stepPrecision(adjustedStep))),
)
onChange(newV) onChange(newV)
setInputValue(newV.toFixed(precision)) setInputValue(newV.toFixed(precision))
} else if (e.key === 'ArrowDown') { } else if (e.key === 'ArrowDown') {
e.preventDefault() e.preventDefault()
const newV = clamp(value - step) const adjustedStep = getAdjustedStep(step, e)
const newV = clamp(
Number.parseFloat((value - adjustedStep).toFixed(stepPrecision(adjustedStep))),
)
onChange(newV) onChange(newV)
setInputValue(newV.toFixed(precision)) setInputValue(newV.toFixed(precision))
} }
@@ -1,13 +1,12 @@
'use client' 'use client'
import { type AnyNode, type CeilingNode, type MaterialSchema, useScene } from '@pascal-app/core' import { type AnyNode, type CeilingNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Edit, Move, Plus, Trash2 } from 'lucide-react' import { Edit, Move, Plus, Trash2 } from 'lucide-react'
import { useCallback, useEffect } from 'react' import { useCallback, useEffect } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button' import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
import { PanelSection } from '../controls/panel-section' import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control' import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper' import { PanelWrapper } from './panel-wrapper'
@@ -32,20 +31,6 @@ export function CeilingPanel() {
[selectedId, updateNode], [selectedId, updateNode],
) )
const handleMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material, materialPreset: undefined })
},
[handleUpdate],
)
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
},
[handleUpdate],
)
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
setEditingHole(null) setEditingHole(null)
@@ -257,15 +242,6 @@ export function CeilingPanel() {
</div> </div>
</PanelSection> </PanelSection>
<PanelSection title="Material">
<MaterialPicker
nodeType="ceiling"
onChange={handleMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</PanelSection>
<ActionGroup> <ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} /> <ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
</ActionGroup> </ActionGroup>
@@ -5,7 +5,6 @@ import {
type AnyNodeId, type AnyNodeId,
DoorNode, DoorNode,
emitter, emitter,
type MaterialSchema,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
@@ -15,7 +14,6 @@ import { usePresetsAdapter } from '../../../contexts/presets-context'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button' import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
import { MetricControl } from '../controls/metric-control' import { MetricControl } from '../controls/metric-control'
import { PanelSection } from '../controls/panel-section' import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control' import { SegmentedControl } from '../controls/segmented-control'
@@ -46,13 +44,6 @@ export function DoorPanel() {
[selectedId, updateNode], [selectedId, updateNode],
) )
const handleMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material })
},
[handleUpdate],
)
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, [setSelection]) }, [setSelection])
@@ -592,9 +583,6 @@ export function DoorPanel() {
/> />
</ActionGroup> </ActionGroup>
</PanelSection> </PanelSection>
<PanelSection title="Material">
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
</PanelSection>
</PanelWrapper> </PanelWrapper>
) )
} }
@@ -1,8 +1,25 @@
'use client' 'use client'
import { type AnyNode, type AnyNodeId, type FenceNode, type MaterialSchema, useScene } from '@pascal-app/core'
import {
type AnyNode,
type AnyNodeId,
type FenceNode,
getClampedWallCurveOffset,
getMaxWallCurveOffset,
getWallCurveLength,
type MaterialSchema,
normalizeWallCurveOffset,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Move, Spline } from 'lucide-react'
import { useCallback } from 'react' import { useCallback } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker' import { MaterialPicker } from '../controls/material-picker'
import { PanelSection } from '../controls/panel-section' import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control' import { SegmentedControl } from '../controls/segmented-control'
@@ -28,6 +45,8 @@ export function FencePanel() {
const selectedCount = useViewer((s) => s.selection.selectedIds.length) const selectedCount = useViewer((s) => s.selection.selectedIds.length)
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode) const updateNode = useScene((s) => s.updateNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const setCurvingFence = useEditor((s) => s.setCurvingFence)
const node = useScene((s) => const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as FenceNode | undefined) : undefined, selectedId ? (s.nodes[selectedId as AnyNode['id']] as FenceNode | undefined) : undefined,
@@ -67,25 +86,15 @@ export function FencePanel() {
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, [setSelection]) }, [setSelection])
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
},
[handleUpdate],
)
const handleCustomMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material, materialPreset: undefined })
},
[handleUpdate],
)
if (!(node && node.type === 'fence' && selectedId && selectedCount === 1)) return null if (!(node && node.type === 'fence' && selectedId && selectedCount === 1)) return null
const dx = node.end[0] - node.start[0] const length = getWallCurveLength(node)
const dz = node.end[1] - node.start[1] const curveOffset = getClampedWallCurveOffset(node)
const length = Math.sqrt(dx * dx + dz * dz) const maxCurveOffset = getMaxWallCurveOffset(node)
return ( return (
<PanelWrapper <PanelWrapper
@@ -119,6 +128,16 @@ export function FencePanel() {
unit="m" unit="m"
value={length} value={length}
/> />
<SliderControl
label="Curve"
max={Math.max(0.01, maxCurveOffset)}
min={-Math.max(0.01, maxCurveOffset)}
onChange={(value) => handleUpdate({ curveOffset: normalizeWallCurveOffset(node, value) })}
precision={2}
step={0.1}
unit="m"
value={Math.round(curveOffset * 100) / 100}
/>
<SliderControl <SliderControl
label="Height" label="Height"
max={4} max={4}
@@ -203,16 +222,6 @@ export function FencePanel() {
value={node.edgeInset} value={node.edgeInset}
/> />
</PanelSection> </PanelSection>
<PanelSection title="Material">
<MaterialPicker
nodeType="fence"
onChange={handleCustomMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</PanelSection>
</PanelWrapper> </PanelWrapper>
) )
} }
@@ -0,0 +1,163 @@
'use client'
import useEditor from '../../../store/use-editor'
import { Input } from '../primitives/input'
import { PanelSection } from '../controls/panel-section'
import { PanelWrapper } from './panel-wrapper'
function buildDefaultCustomMaterial() {
return {
preset: 'custom' as const,
properties: {
color: '#ffffff',
roughness: 0.5,
metalness: 0,
opacity: 1,
transparent: false,
side: 'front' as const,
},
}
}
export function PaintPanel() {
const activePaintMaterial = useEditor((state) => state.activePaintMaterial)
const activePaintTarget = useEditor((state) => state.activePaintTarget)
const setActivePaintMaterial = useEditor((state) => state.setActivePaintMaterial)
const setPaintPanelOpen = useEditor((state) => state.setPaintPanelOpen)
const customMaterial =
activePaintMaterial?.material?.properties && !activePaintMaterial.materialPreset
? activePaintMaterial.material
: null
if (!customMaterial) return null
const currentProps = customMaterial.properties ?? buildDefaultCustomMaterial().properties
const updateCustomMaterial = (
updates: Partial<typeof currentProps>,
nextTransparent = currentProps.transparent,
) => {
setActivePaintMaterial({
material: {
preset: 'custom',
properties: {
...currentProps,
...updates,
transparent: nextTransparent,
},
},
sourceTarget: activePaintMaterial?.sourceTarget ?? activePaintTarget,
})
}
return (
<PanelWrapper
onClose={() => setPaintPanelOpen(false)}
title="Material"
width={320}
>
<PanelSection title="Custom Material">
<div className="space-y-3">
<div className="space-y-2">
<label className="block font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
Color
</label>
<div className="flex items-center gap-2">
<input
className="h-10 w-14 cursor-pointer rounded-md border border-input bg-transparent"
onChange={(e) => updateCustomMaterial({ color: e.target.value })}
type="color"
value={currentProps.color}
/>
<Input
onChange={(e) => updateCustomMaterial({ color: e.target.value })}
value={currentProps.color}
/>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
Roughness
</label>
<span className="font-mono text-muted-foreground text-xs">
{currentProps.roughness.toFixed(2)}
</span>
</div>
<input
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-accent"
max={1}
min={0}
onChange={(e) => updateCustomMaterial({ roughness: Number.parseFloat(e.target.value) })}
step={0.01}
type="range"
value={currentProps.roughness}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
Metalness
</label>
<span className="font-mono text-muted-foreground text-xs">
{currentProps.metalness.toFixed(2)}
</span>
</div>
<input
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-accent"
max={1}
min={0}
onChange={(e) => updateCustomMaterial({ metalness: Number.parseFloat(e.target.value) })}
step={0.01}
type="range"
value={currentProps.metalness}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
Opacity
</label>
<span className="font-mono text-muted-foreground text-xs">
{currentProps.opacity.toFixed(2)}
</span>
</div>
<input
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-accent"
max={1}
min={0}
onChange={(e) => {
const opacity = Number.parseFloat(e.target.value)
updateCustomMaterial({ opacity }, opacity < 1 || currentProps.transparent)
}}
step={0.01}
type="range"
value={currentProps.opacity}
/>
</div>
<div className="space-y-2">
<label className="block font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
Side
</label>
<select
className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-input/30"
onChange={(e) =>
updateCustomMaterial({ side: e.target.value as 'front' | 'back' | 'double' })
}
value={currentProps.side}
>
<option value="front">Front</option>
<option value="back">Back</option>
<option value="double">Double</option>
</select>
</div>
</div>
</PanelSection>
</PanelWrapper>
)
}
@@ -7,6 +7,7 @@ import { CeilingPanel } from './ceiling-panel'
import { DoorPanel } from './door-panel' import { DoorPanel } from './door-panel'
import { FencePanel } from './fence-panel' import { FencePanel } from './fence-panel'
import { ItemPanel } from './item-panel' import { ItemPanel } from './item-panel'
import { PaintPanel } from './paint-panel'
import { ReferencePanel } from './reference-panel' import { ReferencePanel } from './reference-panel'
import { RoofPanel } from './roof-panel' import { RoofPanel } from './roof-panel'
import { RoofSegmentPanel } from './roof-segment-panel' import { RoofSegmentPanel } from './roof-segment-panel'
@@ -19,6 +20,9 @@ import { WindowPanel } from './window-panel'
export function PanelManager() { export function PanelManager() {
const selectedIds = useViewer((s) => s.selection.selectedIds) const selectedIds = useViewer((s) => s.selection.selectedIds)
const selectedReferenceId = useEditor((s) => s.selectedReferenceId) const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
const isPaintPanelOpen = useEditor((s) => s.isPaintPanelOpen)
const mode = useEditor((s) => s.mode)
const activePaintMaterial = useEditor((s) => s.activePaintMaterial)
// Only subscribe to the *type* of the single-selected node — string primitive // Only subscribe to the *type* of the single-selected node — string primitive
// so we don't re-render on unrelated scene mutations. // so we don't re-render on unrelated scene mutations.
const selectedNodeType = useScene((s) => { const selectedNodeType = useScene((s) => {
@@ -32,6 +36,15 @@ export function PanelManager() {
return <ReferencePanel /> return <ReferencePanel />
} }
if (
isPaintPanelOpen &&
mode === 'material-paint' &&
activePaintMaterial?.material?.properties &&
!activePaintMaterial.materialPreset
) {
return <PaintPanel />
}
// Show appropriate panel based on selected node type // Show appropriate panel based on selected node type
if (selectedNodeType) { if (selectedNodeType) {
switch (selectedNodeType) { switch (selectedNodeType) {
@@ -3,7 +3,6 @@
import { import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
type MaterialSchema,
type RoofNode, type RoofNode,
RoofNode as RoofNodeSchema, RoofNode as RoofNodeSchema,
type RoofSegmentNode, type RoofSegmentNode,
@@ -17,7 +16,6 @@ import { useShallow } from 'zustand/react/shallow'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button' import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
import { PanelSection } from '../controls/panel-section' import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control' import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper' import { PanelWrapper } from './panel-wrapper'
@@ -50,20 +48,6 @@ export function RoofPanel() {
[selectedId, updateNode], [selectedId, updateNode],
) )
const handleMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material, materialPreset: undefined })
},
[handleUpdate],
)
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
},
[handleUpdate],
)
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, [setSelection]) }, [setSelection])
@@ -170,11 +154,13 @@ export function RoofPanel() {
</button> </button>
))} ))}
</div> </div>
<ActionButton <ActionGroup>
icon={<Plus className="h-3.5 w-3.5" />} <ActionButton
label="Add Segment" icon={<Plus className="h-3.5 w-3.5" />}
onClick={handleAddSegment} label="Add Segment"
/> onClick={handleAddSegment}
/>
</ActionGroup>
</PanelSection> </PanelSection>
<PanelSection title="Position"> <PanelSection title="Position">
@@ -266,15 +252,6 @@ export function RoofPanel() {
/> />
</ActionGroup> </ActionGroup>
</PanelSection> </PanelSection>
<PanelSection title="Material">
<MaterialPicker
nodeType="roof"
onChange={handleMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</PanelSection>
</PanelWrapper> </PanelWrapper>
) )
} }
@@ -3,7 +3,6 @@
import { import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
type MaterialSchema,
type RoofSegmentNode, type RoofSegmentNode,
RoofSegmentNode as RoofSegmentNodeSchema, RoofSegmentNode as RoofSegmentNodeSchema,
type RoofType, type RoofType,
@@ -15,7 +14,6 @@ import { useCallback } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button' import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
import { PanelSection } from '../controls/panel-section' import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control' import { SegmentedControl } from '../controls/segmented-control'
import { SliderControl } from '../controls/slider-control' import { SliderControl } from '../controls/slider-control'
@@ -52,20 +50,6 @@ export function RoofSegmentPanel() {
[selectedId, updateNode], [selectedId, updateNode],
) )
const handleMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material, materialPreset: undefined })
},
[handleUpdate],
)
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
},
[handleUpdate],
)
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, [setSelection]) }, [setSelection])
@@ -322,15 +306,6 @@ export function RoofSegmentPanel() {
/> />
</ActionGroup> </ActionGroup>
</PanelSection> </PanelSection>
<PanelSection title="Material">
<MaterialPicker
nodeType="roof-segment"
onChange={handleMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</PanelSection>
</PanelWrapper> </PanelWrapper>
) )
} }
@@ -1,13 +1,12 @@
'use client' 'use client'
import { type AnyNode, type MaterialSchema, type SlabNode, useScene } from '@pascal-app/core' import { type AnyNode, type SlabNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Edit, Move, Plus, Trash2 } from 'lucide-react' import { Edit, Move, Plus, Trash2 } from 'lucide-react'
import { useCallback, useEffect } from 'react' import { useCallback, useEffect } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button' import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
import { PanelSection } from '../controls/panel-section' import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control' import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper' import { PanelWrapper } from './panel-wrapper'
@@ -32,20 +31,6 @@ export function SlabPanel() {
[selectedId, updateNode], [selectedId, updateNode],
) )
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
},
[handleUpdate],
)
const handleCustomMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material, materialPreset: undefined })
},
[handleUpdate],
)
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
setEditingHole(null) setEditingHole(null)
@@ -257,15 +242,6 @@ export function SlabPanel() {
/> />
</div> </div>
</PanelSection> </PanelSection>
<PanelSection title="Material">
<MaterialPicker
nodeType="slab"
onChange={handleCustomMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</PanelSection>
<ActionGroup> <ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} /> <ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
</ActionGroup> </ActionGroup>
@@ -4,7 +4,6 @@ import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
type LevelNode, type LevelNode,
type MaterialSchema,
type StairNode, type StairNode,
type StairRailingMode, type StairRailingMode,
type StairSlabOpeningMode, type StairSlabOpeningMode,
@@ -23,7 +22,6 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '../../tools/stair/stair-defaults' import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '../../tools/stair/stair-defaults'
import { ActionButton, ActionGroup } from '../controls/action-button' import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
import { MetricControl } from '../controls/metric-control' import { MetricControl } from '../controls/metric-control'
import { PanelSection } from '../controls/panel-section' import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control' import { SegmentedControl } from '../controls/segmented-control'
@@ -92,20 +90,6 @@ export function StairPanel() {
[selectedId, updateNode], [selectedId, updateNode],
) )
const handleMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material, materialPreset: undefined })
},
[handleUpdate],
)
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
},
[handleUpdate],
)
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, [setSelection]) }, [setSelection])
@@ -568,15 +552,6 @@ export function StairPanel() {
/> />
</ActionGroup> </ActionGroup>
</PanelSection> </PanelSection>
<PanelSection title="Material">
<MaterialPicker
nodeType="stair"
onChange={handleMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</PanelSection>
</PanelWrapper> </PanelWrapper>
) )
} }
@@ -4,7 +4,6 @@ import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
type AttachmentSide, type AttachmentSide,
type MaterialSchema,
type StairSegmentNode, type StairSegmentNode,
StairSegmentNode as StairSegmentNodeSchema, StairSegmentNode as StairSegmentNodeSchema,
type StairSegmentType, type StairSegmentType,
@@ -16,7 +15,6 @@ import { useCallback } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button' import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
import { PanelSection } from '../controls/panel-section' import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control' import { SegmentedControl } from '../controls/segmented-control'
import { SliderControl } from '../controls/slider-control' import { SliderControl } from '../controls/slider-control'
@@ -61,20 +59,6 @@ export function StairSegmentPanel() {
[selectedId, updateNode], [selectedId, updateNode],
) )
const handleMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material, materialPreset: undefined })
},
[handleUpdate],
)
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
},
[handleUpdate],
)
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, [setSelection]) }, [setSelection])
@@ -336,15 +320,6 @@ export function StairSegmentPanel() {
/> />
</ActionGroup> </ActionGroup>
</PanelSection> </PanelSection>
<PanelSection title="Material">
<MaterialPicker
nodeType="stair-segment"
onChange={handleMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</PanelSection>
</PanelWrapper> </PanelWrapper>
) )
} }
@@ -7,7 +7,6 @@ import {
getMaxWallCurveOffset, getMaxWallCurveOffset,
getWallCurveLength, getWallCurveLength,
normalizeWallCurveOffset, normalizeWallCurveOffset,
type MaterialSchema,
useScene, useScene,
type WallNode, type WallNode,
} from '@pascal-app/core' } from '@pascal-app/core'
@@ -17,7 +16,6 @@ import { useCallback } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button' import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
import { PanelSection } from '../controls/panel-section' import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control' import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper' import { PanelWrapper } from './panel-wrapper'
@@ -81,20 +79,6 @@ export function WallPanel() {
[node, handleUpdate], [node, handleUpdate],
) )
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
},
[handleUpdate],
)
const handleCustomMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material, materialPreset: undefined })
},
[handleUpdate],
)
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, [setSelection]) }, [setSelection])
@@ -169,33 +153,25 @@ export function WallPanel() {
min={-Math.max(0.01, maxCurveOffset)} min={-Math.max(0.01, maxCurveOffset)}
onChange={(v) => handleUpdate({ curveOffset: normalizeWallCurveOffset(node, v) })} onChange={(v) => handleUpdate({ curveOffset: normalizeWallCurveOffset(node, v) })}
precision={2} precision={2}
step={0.01} step={0.1}
unit="m" unit="m"
value={Math.round(curveOffset * 100) / 100} value={Math.round(curveOffset * 100) / 100}
/> />
)} )}
</PanelSection> </PanelSection>
<PanelSection title="Material"> <PanelSection title="Actions">
<MaterialPicker <ActionGroup>
nodeType="wall" <ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
onChange={handleCustomMaterialChange} {!hasWallChildrenBlockingCurve && (
onSelectMaterialPreset={handleMaterialPresetChange} <ActionButton
selectedMaterialPreset={node.materialPreset} icon={<Spline className="h-3.5 w-3.5" />}
value={node.material} label="Curve"
/> onClick={handleCurve}
/>
)}
</ActionGroup>
</PanelSection> </PanelSection>
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
{!hasWallChildrenBlockingCurve && (
<ActionButton
icon={<Spline className="h-3.5 w-3.5" />}
label="Curve"
onClick={handleCurve}
/>
)}
</ActionGroup>
</PanelWrapper> </PanelWrapper>
) )
} }
@@ -4,7 +4,6 @@ import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
emitter, emitter,
type MaterialSchema,
useScene, useScene,
WindowNode, WindowNode,
} from '@pascal-app/core' } from '@pascal-app/core'
@@ -15,7 +14,6 @@ import { usePresetsAdapter } from '../../../contexts/presets-context'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button' import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
import { MetricControl } from '../controls/metric-control' import { MetricControl } from '../controls/metric-control'
import { PanelSection } from '../controls/panel-section' import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control' import { SliderControl } from '../controls/slider-control'
@@ -45,13 +43,6 @@ export function WindowPanel() {
[selectedId, updateNode], [selectedId, updateNode],
) )
const handleMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material })
},
[handleUpdate],
)
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, [setSelection]) }, [setSelection])
@@ -431,9 +422,6 @@ export function WindowPanel() {
/> />
</ActionGroup> </ActionGroup>
</PanelSection> </PanelSection>
<PanelSection title="Material">
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
</PanelSection>
</PanelWrapper> </PanelWrapper>
) )
} }
+10 -2
View File
@@ -1,6 +1,7 @@
import { type AnyNodeId, emitter, useScene } from '@pascal-app/core' import { type AnyNodeId, emitter, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react' import { useEffect } from 'react'
import { runRedo, runUndo } from '../lib/history'
import { sfxEmitter } from '../lib/sfx-bus' import { sfxEmitter } from '../lib/sfx-bus'
import useEditor from '../store/use-editor' import useEditor from '../store/use-editor'
@@ -88,14 +89,21 @@ export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => {
if (isVersionPreviewMode) return if (isVersionPreviewMode) return
e.preventDefault() e.preventDefault()
useEditor.getState().setMode('delete') useEditor.getState().setMode('delete')
} else if (e.key === 'p' && !e.metaKey && !e.ctrlKey) {
if (isVersionPreviewMode) return
e.preventDefault()
useEditor.getState().primeMaterialPaintFromSelection()
useEditor.getState().setPhase('structure')
useEditor.getState().setStructureLayer('elements')
useEditor.getState().setMode('material-paint')
} else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
if (isVersionPreviewMode) return if (isVersionPreviewMode) return
e.preventDefault() e.preventDefault()
useScene.temporal.getState().undo() runUndo()
} else if (e.key === 'Z' && e.shiftKey && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'Z' && e.shiftKey && (e.metaKey || e.ctrlKey)) {
if (isVersionPreviewMode) return if (isVersionPreviewMode) return
e.preventDefault() e.preventDefault()
useScene.temporal.getState().redo() runRedo()
} else if (e.key === 'ArrowUp' && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'ArrowUp' && (e.metaKey || e.ctrlKey)) {
e.preventDefault() e.preventDefault()
const { buildingId, levelId } = useViewer.getState().selection const { buildingId, levelId } = useViewer.getState().selection
+20
View File
@@ -0,0 +1,20 @@
import { useLiveTransforms, useScene } from '@pascal-app/core'
function refreshSceneAfterHistoryJump() {
useLiveTransforms.getState().clearAll()
const state = useScene.getState()
for (const node of Object.values(state.nodes)) {
state.markDirty(node.id)
}
}
export function runUndo() {
useScene.temporal.getState().undo()
refreshSceneAfterHistoryJump()
}
export function runRedo() {
useScene.temporal.getState().redo()
refreshSceneAfterHistoryJump()
}
+279
View File
@@ -0,0 +1,279 @@
'use client'
import {
type CeilingNode,
type FenceNode,
getCatalogMaterialById,
getEffectiveRoofSurfaceMaterial,
getEffectiveStairSurfaceMaterial,
getEffectiveWallSurfaceMaterial,
getLibraryMaterialIdFromRef,
type MaterialSchema,
type MaterialTarget,
type RoofNode,
type RoofSurfaceMaterialRole,
type SlabNode,
type StairNode,
type StairSurfaceMaterialRole,
type WallNode,
type WallSurfaceSide,
} from '@pascal-app/core'
export type PaintableMaterialTarget = Extract<
MaterialTarget,
'wall' | 'roof' | 'stair' | 'fence' | 'slab' | 'ceiling'
>
export type SingleSurfaceMaterialRole = 'surface'
export type ActivePaintMaterial = {
material?: MaterialSchema
materialPreset?: string
sourceTarget: PaintableMaterialTarget
}
export function hasActivePaintMaterial(
material: ActivePaintMaterial | null | undefined,
): material is ActivePaintMaterial {
return Boolean(
material && (material.material !== undefined || material.materialPreset !== undefined),
)
}
function getCatalogEntryForActivePaintMaterial(material: ActivePaintMaterial | null | undefined) {
const catalogId =
getLibraryMaterialIdFromRef(material?.materialPreset) ?? material?.material?.id ?? undefined
return getCatalogMaterialById(catalogId)
}
export function getActivePaintMaterialLabel(material: ActivePaintMaterial | null | undefined) {
return getCatalogEntryForActivePaintMaterial(material)?.label ?? 'Custom'
}
export function buildWallSurfaceMaterialPatch(
node: WallNode,
targetSide: WallSurfaceSide,
material: MaterialSchema | undefined,
materialPreset: string | undefined,
): Partial<WallNode> {
const nextSurfaceMaterial = { material, materialPreset }
const nextInterior =
targetSide === 'interior'
? nextSurfaceMaterial
: getEffectiveWallSurfaceMaterial(node, 'interior')
const nextExterior =
targetSide === 'exterior'
? nextSurfaceMaterial
: getEffectiveWallSurfaceMaterial(node, 'exterior')
return {
interiorMaterial: nextInterior.material,
interiorMaterialPreset: nextInterior.materialPreset,
exteriorMaterial: nextExterior.material,
exteriorMaterialPreset: nextExterior.materialPreset,
material: undefined,
materialPreset: undefined,
}
}
export function buildRoofSurfaceMaterialPatch(
node: RoofNode,
targetRole: RoofSurfaceMaterialRole,
material: MaterialSchema | undefined,
materialPreset: string | undefined,
): Partial<RoofNode> {
const nextSurfaceMaterial = { material, materialPreset }
const nextTop =
targetRole === 'top' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'top')
const nextEdge =
targetRole === 'edge' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'edge')
const nextWall =
targetRole === 'wall' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'wall')
return {
topMaterial: nextTop.material,
topMaterialPreset: nextTop.materialPreset,
edgeMaterial: nextEdge.material,
edgeMaterialPreset: nextEdge.materialPreset,
wallMaterial: nextWall.material,
wallMaterialPreset: nextWall.materialPreset,
material: undefined,
materialPreset: undefined,
}
}
export function buildStairSurfaceMaterialPatch(
node: StairNode,
targetRole: StairSurfaceMaterialRole,
material: MaterialSchema | undefined,
materialPreset: string | undefined,
): Partial<StairNode> {
const nextSurfaceMaterial = { material, materialPreset }
const nextRailing =
targetRole === 'railing'
? nextSurfaceMaterial
: getEffectiveStairSurfaceMaterial(node, 'railing')
const nextTread =
targetRole === 'tread' ? nextSurfaceMaterial : getEffectiveStairSurfaceMaterial(node, 'tread')
const nextSide =
targetRole === 'side' ? nextSurfaceMaterial : getEffectiveStairSurfaceMaterial(node, 'side')
return {
railingMaterial: nextRailing.material,
railingMaterialPreset: nextRailing.materialPreset,
treadMaterial: nextTread.material,
treadMaterialPreset: nextTread.materialPreset,
sideMaterial: nextSide.material,
sideMaterialPreset: nextSide.materialPreset,
material: undefined,
materialPreset: undefined,
}
}
export function buildSingleSurfaceMaterialPatch<TNode extends FenceNode | SlabNode | CeilingNode>(
material: MaterialSchema | undefined,
materialPreset: string | undefined,
): Partial<TNode> {
return {
material,
materialPreset,
} as Partial<TNode>
}
export function resolveActivePaintMaterialFromSelection(params: {
nodes: Record<string, any>
selectedId: string | null
selectedMaterialTarget: {
nodeId: string
role:
| WallSurfaceSide
| StairSurfaceMaterialRole
| RoofSurfaceMaterialRole
| SingleSurfaceMaterialRole
} | null
}): ActivePaintMaterial | null {
const { nodes, selectedId, selectedMaterialTarget } = params
if (!selectedId || !selectedMaterialTarget || selectedMaterialTarget.nodeId !== selectedId)
return null
const selectedNode = nodes[selectedId]
if (!selectedNode) return null
if (
selectedNode.type === 'wall' &&
(selectedMaterialTarget.role === 'interior' || selectedMaterialTarget.role === 'exterior')
) {
const surface = getEffectiveWallSurfaceMaterial(selectedNode, selectedMaterialTarget.role)
return hasActivePaintMaterial({
material: surface.material,
materialPreset: surface.materialPreset,
sourceTarget: 'wall',
})
? {
material: surface.material,
materialPreset: surface.materialPreset,
sourceTarget: 'wall',
}
: null
}
if (
selectedNode.type === 'roof' &&
(selectedMaterialTarget.role === 'top' ||
selectedMaterialTarget.role === 'edge' ||
selectedMaterialTarget.role === 'wall')
) {
const surface = getEffectiveRoofSurfaceMaterial(selectedNode, selectedMaterialTarget.role)
return hasActivePaintMaterial({
material: surface.material,
materialPreset: surface.materialPreset,
sourceTarget: 'roof',
})
? {
material: surface.material,
materialPreset: surface.materialPreset,
sourceTarget: 'roof',
}
: null
}
if (
selectedNode.type === 'stair' &&
(selectedMaterialTarget.role === 'railing' ||
selectedMaterialTarget.role === 'tread' ||
selectedMaterialTarget.role === 'side')
) {
const surface = getEffectiveStairSurfaceMaterial(selectedNode, selectedMaterialTarget.role)
return hasActivePaintMaterial({
material: surface.material,
materialPreset: surface.materialPreset,
sourceTarget: 'stair',
})
? {
material: surface.material,
materialPreset: surface.materialPreset,
sourceTarget: 'stair',
}
: null
}
if (
(selectedNode.type === 'fence' ||
selectedNode.type === 'slab' ||
selectedNode.type === 'ceiling') &&
selectedMaterialTarget.role === 'surface'
) {
const target = selectedNode.type
return hasActivePaintMaterial({
material: selectedNode.material,
materialPreset: selectedNode.materialPreset,
sourceTarget: target,
})
? {
material: selectedNode.material,
materialPreset: selectedNode.materialPreset,
sourceTarget: target,
}
: null
}
return null
}
export function resolvePaintTargetFromSelection(params: {
nodes: Record<string, any>
selectedId: string | null
}): PaintableMaterialTarget | null {
const { nodes, selectedId } = params
if (!selectedId) return null
const selectedNode = nodes[selectedId]
if (!selectedNode) return null
if (selectedNode.type === 'wall') {
return 'wall'
}
if (selectedNode.type === 'roof' || selectedNode.type === 'roof-segment') {
return 'roof'
}
if (selectedNode.type === 'stair' || selectedNode.type === 'stair-segment') {
return 'stair'
}
if (selectedNode.type === 'fence') {
return 'fence'
}
if (selectedNode.type === 'slab') {
return 'slab'
}
if (selectedNode.type === 'ceiling') {
return 'ceiling'
}
return null
}
+96 -3
View File
@@ -1,7 +1,8 @@
'use client' 'use client'
import type { AssetInput } from '@pascal-app/core'
import { import {
type AnyNodeId,
type AssetInput,
type BuildingNode, type BuildingNode,
type CeilingNode, type CeilingNode,
type DoorNode, type DoorNode,
@@ -10,18 +11,28 @@ import {
type LevelNode, type LevelNode,
type RoofNode, type RoofNode,
type RoofSegmentNode, type RoofSegmentNode,
type RoofSurfaceMaterialRole,
type SlabNode, type SlabNode,
type Space, type Space,
type StairNode, type StairNode,
type StairSegmentNode, type StairSegmentNode,
type StairSurfaceMaterialRole,
useScene, useScene,
type WallNode, type WallNode,
type WallSurfaceSide,
type WindowNode, type WindowNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { create } from 'zustand' import { create } from 'zustand'
import { persist } from 'zustand/middleware' import { persist } from 'zustand/middleware'
import { getDefaultCatalogItem } from '../components/ui/item-catalog/catalog-items' import { getDefaultCatalogItem } from '../components/ui/item-catalog/catalog-items'
import {
type ActivePaintMaterial,
type PaintableMaterialTarget,
resolveActivePaintMaterialFromSelection,
resolvePaintTargetFromSelection,
type SingleSurfaceMaterialRole,
} from '../lib/material-paint'
const DEFAULT_ACTIVE_SIDEBAR_PANEL = 'site' const DEFAULT_ACTIVE_SIDEBAR_PANEL = 'site'
const DEFAULT_FLOORPLAN_PANE_RATIO = 0.5 const DEFAULT_FLOORPLAN_PANE_RATIO = 0.5
@@ -33,7 +44,7 @@ export type SplitOrientation = 'horizontal' | 'vertical'
export type Phase = 'site' | 'structure' | 'furnish' export type Phase = 'site' | 'structure' | 'furnish'
export type Mode = 'select' | 'edit' | 'delete' | 'build' export type Mode = 'select' | 'edit' | 'delete' | 'build' | 'material-paint'
// Structure mode tools (building elements) // Structure mode tools (building elements)
export type StructureTool = export type StructureTool =
@@ -80,6 +91,24 @@ export type MovingWallEndpoint = {
endpoint: 'start' | 'end' endpoint: 'start' | 'end'
} }
export type MovingFenceEndpoint = {
fence: FenceNode
endpoint: 'start' | 'end'
}
export type MaterialTargetRole = WallSurfaceSide | StairSurfaceMaterialRole | RoofSurfaceMaterialRole | SingleSurfaceMaterialRole
export type SelectedMaterialTarget = {
nodeId: AnyNodeId
role: MaterialTargetRole
}
type MaterialPaintSelectionSnapshot = {
selectedId: string | null
activePaintTarget: PaintableMaterialTarget
activePaintMaterial: ActivePaintMaterial | null
}
type EditorState = { type EditorState = {
phase: Phase phase: Phase
setPhase: (phase: Phase) => void setPhase: (phase: Phase) => void
@@ -125,8 +154,23 @@ type EditorState = {
) => void ) => void
movingWallEndpoint: MovingWallEndpoint | null movingWallEndpoint: MovingWallEndpoint | null
setMovingWallEndpoint: (value: MovingWallEndpoint | null) => void setMovingWallEndpoint: (value: MovingWallEndpoint | null) => void
movingFenceEndpoint: MovingFenceEndpoint | null
setMovingFenceEndpoint: (value: MovingFenceEndpoint | null) => void
curvingWall: WallNode | null curvingWall: WallNode | null
setCurvingWall: (wall: WallNode | null) => void setCurvingWall: (wall: WallNode | null) => void
curvingFence: FenceNode | null
setCurvingFence: (fence: FenceNode | null) => void
selectedMaterialTarget: SelectedMaterialTarget | null
setSelectedMaterialTarget: (target: SelectedMaterialTarget | null) => void
activePaintMaterial: ActivePaintMaterial | null
setActivePaintMaterial: (material: ActivePaintMaterial | null) => void
activePaintTarget: PaintableMaterialTarget
setActivePaintTarget: (target: PaintableMaterialTarget) => void
primeMaterialPaintFromSelection: () => MaterialPaintSelectionSnapshot
hoveredPaintTarget: PaintableMaterialTarget | null
setHoveredPaintTarget: (target: PaintableMaterialTarget | null) => void
isPaintPanelOpen: boolean
setPaintPanelOpen: (open: boolean) => void
selectedReferenceId: string | null selectedReferenceId: string | null
setSelectedReferenceId: (id: string | null) => void setSelectedReferenceId: (id: string | null) => void
// Space detection for cutaway mode // Space detection for cutaway mode
@@ -206,7 +250,7 @@ function normalizeModeForPhase(phase: Phase, mode: Mode | undefined): Mode {
return 'select' return 'select'
} }
return mode === 'build' || mode === 'delete' ? mode : 'select' return mode === 'build' || mode === 'delete' || mode === 'material-paint' ? mode : 'select'
} }
function normalizeFloorplanPaneRatio(value: unknown): number { function normalizeFloorplanPaneRatio(value: unknown): number {
@@ -444,6 +488,8 @@ const useEditor = create<EditorState>()(
const category = get().catalogCategory ?? 'furniture' const category = get().catalogCategory ?? 'furniture'
set({ selectedItem: getDefaultSelectedItemForCategory(category) }) set({ selectedItem: getDefaultSelectedItemForCategory(category) })
} }
} else if (mode === 'material-paint') {
get().primeMaterialPaintFromSelection()
} }
// When leaving build mode, clear tool // When leaving build mode, clear tool
else if (tool) { else if (tool) {
@@ -500,8 +546,55 @@ const useEditor = create<EditorState>()(
setMovingNode: (node) => set({ movingNode: node }), setMovingNode: (node) => set({ movingNode: node }),
movingWallEndpoint: null, movingWallEndpoint: null,
setMovingWallEndpoint: (value) => set({ movingWallEndpoint: value }), setMovingWallEndpoint: (value) => set({ movingWallEndpoint: value }),
movingFenceEndpoint: null,
setMovingFenceEndpoint: (value) => set({ movingFenceEndpoint: value }),
curvingWall: null, curvingWall: null,
setCurvingWall: (wall) => set({ curvingWall: wall }), setCurvingWall: (wall) => set({ curvingWall: wall }),
curvingFence: null,
setCurvingFence: (fence) => set({ curvingFence: fence }),
selectedMaterialTarget: null,
setSelectedMaterialTarget: (target) => set({ selectedMaterialTarget: target }),
activePaintMaterial: null,
setActivePaintMaterial: (material) => set({ activePaintMaterial: material }),
activePaintTarget: 'wall',
setActivePaintTarget: (target) =>
set((state) =>
state.activePaintTarget === target ? state : { activePaintTarget: target },
),
primeMaterialPaintFromSelection: () => {
const selectedId =
useViewer.getState().selection.selectedIds.length === 1
? (useViewer.getState().selection.selectedIds[0] ?? null)
: null
const activePaintTarget =
resolvePaintTargetFromSelection({
nodes: useScene.getState().nodes,
selectedId,
}) ?? get().activePaintTarget
const activePaintMaterial = resolveActivePaintMaterialFromSelection({
nodes: useScene.getState().nodes,
selectedId,
selectedMaterialTarget: get().selectedMaterialTarget,
})
set({
activePaintTarget,
...(activePaintMaterial ? { activePaintMaterial } : {}),
})
return {
selectedId,
activePaintTarget,
activePaintMaterial: activePaintMaterial ?? get().activePaintMaterial,
}
},
hoveredPaintTarget: null,
setHoveredPaintTarget: (target) =>
set((state) =>
state.hoveredPaintTarget === target ? state : { hoveredPaintTarget: target },
),
isPaintPanelOpen: false,
setPaintPanelOpen: (open) => set({ isPaintPanelOpen: open }),
selectedReferenceId: null, selectedReferenceId: null,
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }), setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
spaces: {}, spaces: {},
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@pascal-app/viewer", "name": "@pascal-app/viewer",
"version": "0.5.1", "version": "0.6.0",
"description": "3D viewer component for Pascal building editor", "description": "3D viewer component for Pascal building editor",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
@@ -22,7 +22,7 @@
"prepublishOnly": "npm run build" "prepublishOnly": "npm run build"
}, },
"peerDependencies": { "peerDependencies": {
"@pascal-app/core": "^0.5.1", "@pascal-app/core": "^0.6.0",
"@react-three/drei": "^10", "@react-three/drei": "^10",
"@react-three/fiber": "^9", "@react-three/fiber": "^9",
"react": "^18 || ^19", "react": "^18 || ^19",
@@ -1,4 +1,9 @@
import { type CeilingNode, getMaterialPresetByRef, resolveMaterial, useRegistry } from '@pascal-app/core' import {
type CeilingNode,
getMaterialPresetByRef,
resolveMaterial,
useRegistry,
} from '@pascal-app/core'
import { useMemo, useRef } from 'react' import { useMemo, useRef } from 'react'
import { float, mix, positionWorld, smoothstep } from 'three/tsl' import { float, mix, positionWorld, smoothstep } from 'three/tsl'
import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu' import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu'
@@ -32,6 +37,18 @@ function createCeilingMaterials(color = '#999999') {
return { topMaterial, bottomMaterial } return { topMaterial, bottomMaterial }
} }
const ceilingMaterialCache = new Map<string, ReturnType<typeof createCeilingMaterials>>()
function getCeilingMaterials(color = '#999999') {
const cacheKey = color
const cached = ceilingMaterialCache.get(cacheKey)
if (cached) return cached
const materials = createCeilingMaterials(color)
ceilingMaterialCache.set(cacheKey, materials)
return materials
}
export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
const ref = useRef<Mesh>(null!) const ref = useRef<Mesh>(null!)
@@ -42,8 +59,14 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
const preset = getMaterialPresetByRef(node.materialPreset) const preset = getMaterialPresetByRef(node.materialPreset)
const props = preset?.mapProperties ?? resolveMaterial(node.material) const props = preset?.mapProperties ?? resolveMaterial(node.material)
const color = props.color || '#999999' const color = props.color || '#999999'
return createCeilingMaterials(color) return getCeilingMaterials(color)
}, [node.materialPreset, node.material, node.material?.preset, node.material?.properties, node.material?.texture]) }, [
node.materialPreset,
node.material,
node.material?.preset,
node.material?.properties,
node.material?.texture,
])
return ( return (
<mesh material={materials.bottomMaterial} ref={ref}> <mesh material={materials.bottomMaterial} ref={ref}>
@@ -31,7 +31,14 @@ export const FenceRenderer = ({ node }: { node: FenceNode }) => {
}, [node.id]) }, [node.id])
return ( return (
<mesh castShadow material={material} receiveShadow ref={ref} visible={node.visible} {...handlers}> <mesh
castShadow
material={material}
receiveShadow
ref={ref}
visible={node.visible}
{...handlers}
>
<boxGeometry args={[0, 0, 0]} /> <boxGeometry args={[0, 0, 0]} />
</mesh> </mesh>
) )
@@ -1,9 +1,15 @@
import { type AnyNodeId, type RoofNode, type RoofSegmentNode, useRegistry, useScene } from '@pascal-app/core' import {
import { useMemo, useRef } from 'react' type AnyNodeId,
import type * as THREE from 'three' type RoofNode,
type RoofSegmentNode,
useRegistry,
useScene,
} from '@pascal-app/core'
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events' import { useNodeEvents } from '../../../hooks/use-node-events'
import { createMaterial, createMaterialFromPresetRef } from '../../../lib/materials'
import useViewer from '../../../store/use-viewer' import useViewer from '../../../store/use-viewer'
import { getRoofMaterialArray } from '../../../systems/roof/roof-materials'
import { roofDebugMaterials, roofMaterials } from '../roof/roof-materials' import { roofDebugMaterials, roofMaterials } from '../roof/roof-materials'
export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => { export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
@@ -14,44 +20,44 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
const handlers = useNodeEvents(node, 'roof-segment') const handlers = useNodeEvents(node, 'roof-segment')
const debugColors = useViewer((s) => s.debugColors) const debugColors = useViewer((s) => s.debugColors)
const parentNode = const parentNode = node.parentId
node.parentId ? (nodes[node.parentId as AnyNodeId] as RoofNode | undefined) : undefined ? (nodes[node.parentId as AnyNodeId] as RoofNode | undefined)
: undefined
const placeholderGeometry = useMemo(() => {
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
geometry.addGroup(0, 0, 0)
geometry.addGroup(0, 0, 1)
geometry.addGroup(0, 0, 2)
geometry.addGroup(0, 0, 3)
return geometry
}, [])
const customMaterial = useMemo(() => { const customMaterial = useMemo(() => {
const effectiveMaterialPreset = node.materialPreset ?? parentNode?.materialPreset if (node.material !== undefined || typeof node.materialPreset === 'string') {
const effectiveMaterial = node.material ?? parentNode?.material return null
}
const presetMaterial = createMaterialFromPresetRef(effectiveMaterialPreset) return parentNode ? getRoofMaterialArray(parentNode) : null
if (presetMaterial) return presetMaterial }, [node, parentNode])
const mat = effectiveMaterial
if (!mat) return null
return createMaterial(mat)
}, [
node.materialPreset,
node.material,
node.material?.preset,
node.material?.properties,
node.material?.texture,
parentNode?.materialPreset,
parentNode?.material,
parentNode?.material?.preset,
parentNode?.material?.properties,
parentNode?.material?.texture,
])
const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials
useEffect(() => {
return () => {
placeholderGeometry.dispose()
}
}, [placeholderGeometry])
return ( return (
<mesh <mesh
geometry={placeholderGeometry}
material={material} material={material}
position={node.position} position={node.position}
ref={ref} ref={ref}
rotation-y={node.rotation} rotation-y={node.rotation}
visible={node.visible} visible={node.visible}
{...handlers} {...handlers}
> />
{/* RoofSystem will replace this geometry in the next frame */}
<boxGeometry args={[0, 0, 0]} />
</mesh>
) )
} }
@@ -1,9 +1,9 @@
import { type RoofNode, useRegistry } from '@pascal-app/core' import { type RoofNode, useRegistry } from '@pascal-app/core'
import { useMemo, useRef } from 'react' import { useEffect, useMemo, useRef } from 'react'
import type * as THREE from 'three' import * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events' import { useNodeEvents } from '../../../hooks/use-node-events'
import { createMaterial, createMaterialFromPresetRef } from '../../../lib/materials'
import useViewer from '../../../store/use-viewer' import useViewer from '../../../store/use-viewer'
import { getRoofMaterialArray } from '../../../systems/roof/roof-materials'
import { NodeRenderer } from '../node-renderer' import { NodeRenderer } from '../node-renderer'
import { roofDebugMaterials, roofMaterials } from './roof-materials' import { roofDebugMaterials, roofMaterials } from './roof-materials'
@@ -14,17 +14,26 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => {
const handlers = useNodeEvents(node, 'roof') const handlers = useNodeEvents(node, 'roof')
const debugColors = useViewer((s) => s.debugColors) const debugColors = useViewer((s) => s.debugColors)
const placeholderGeometry = useMemo(() => {
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
geometry.addGroup(0, 0, 0)
geometry.addGroup(0, 0, 1)
geometry.addGroup(0, 0, 2)
geometry.addGroup(0, 0, 3)
return geometry
}, [])
const customMaterial = useMemo(() => { const customMaterial = useMemo(() => getRoofMaterialArray(node), [node])
const presetMaterial = createMaterialFromPresetRef(node.materialPreset)
if (presetMaterial) return presetMaterial
const mat = node.material
if (!mat) return null
return createMaterial(mat)
}, [node.materialPreset, node.material, node.material?.preset, node.material?.properties, node.material?.texture])
const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials
useEffect(() => {
return () => {
placeholderGeometry.dispose()
}
}, [placeholderGeometry])
return ( return (
<group <group
position={node.position} position={node.position}
@@ -33,9 +42,13 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => {
visible={node.visible} visible={node.visible}
{...handlers} {...handlers}
> >
<mesh castShadow material={material} name="merged-roof" receiveShadow> <mesh
<boxGeometry args={[0, 0, 0]} /> castShadow
</mesh> geometry={placeholderGeometry}
material={material}
name="merged-roof"
receiveShadow
/>
<group name="segments-wrapper" visible={false}> <group name="segments-wrapper" visible={false}>
{(node.children ?? []).map((childId) => ( {(node.children ?? []).map((childId) => (
<NodeRenderer key={childId} nodeId={childId} /> <NodeRenderer key={childId} nodeId={childId} />
@@ -55,7 +55,16 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
}) })
const next = nodeList const next = nodeList
.filter((n): n is SlabNode => n.type === 'slab' && n.visible && n.polygon.length >= 3) .filter(
(n): n is SlabNode =>
n.type === 'slab' &&
n.visible &&
n.polygon.length >= 3 &&
// Only recessed slabs should punch through the site ground.
// Positive slabs are real floor geometry and should not create a
// ghost footprint in the background ground fill.
(n.elevation ?? 0.05) < 0,
)
.filter((n) => { .filter((n) => {
if (!Number.isFinite(lowestLevelIndex)) return true if (!Number.isFinite(lowestLevelIndex)) return true
const parentLevel = n.parentId ? levelIndexById.get(n.parentId as string) : undefined const parentLevel = n.parentId ? levelIndexById.get(n.parentId as string) : undefined
@@ -1,14 +1,50 @@
import { type SlabNode, useRegistry } from '@pascal-app/core' import { getMaterialPresetByRef, type SlabNode, useRegistry } from '@pascal-app/core'
import { useEffect, useMemo, useRef } from 'react' import { useMemo, useRef } from 'react'
import * as THREE from 'three'
import type { Mesh } from 'three' import type { Mesh } from 'three'
import * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events' import { useNodeEvents } from '../../../hooks/use-node-events'
import { import {
applyMaterialPresetToMaterials,
createMaterial, createMaterial,
createMaterialFromPresetRef,
DEFAULT_SLAB_MATERIAL, DEFAULT_SLAB_MATERIAL,
} from '../../../lib/materials' } from '../../../lib/materials'
const slabMaterialCache = new Map<string, THREE.MeshStandardMaterial>()
function getSlabMaterial(
cacheKey: string,
params: { material?: SlabNode['material']; materialPreset?: string },
) {
const cached = slabMaterialCache.get(cacheKey)
if (cached) return cached
const preset = getMaterialPresetByRef(params.materialPreset)
const slabMaterial = preset
? new THREE.MeshStandardMaterial()
: params.material
? createMaterial(params.material).clone()
: DEFAULT_SLAB_MATERIAL.clone()
if (preset) {
// Apply the preset to the slab-owned material so async texture loads update
// the instance we actually render after refresh as well.
applyMaterialPresetToMaterials(slabMaterial, preset)
}
// Slabs participate in the WebGPU MRT scene pass. Keeping them opaque avoids
// pipeline variants that can fail when geometry is regenerated while a
// transparent/custom material is attached.
slabMaterial.transparent = false
slabMaterial.opacity = 1
slabMaterial.alphaMap = null
slabMaterial.side = THREE.DoubleSide
slabMaterial.depthWrite = true
slabMaterial.needsUpdate = true
slabMaterialCache.set(cacheKey, slabMaterial)
return slabMaterial
}
export const SlabRenderer = ({ node }: { node: SlabNode }) => { export const SlabRenderer = ({ node }: { node: SlabNode }) => {
const ref = useRef<Mesh>(null!) const ref = useRef<Mesh>(null!)
@@ -17,21 +53,17 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => {
const handlers = useNodeEvents(node, 'slab') const handlers = useNodeEvents(node, 'slab')
const material = useMemo(() => { const material = useMemo(() => {
const presetMaterial = createMaterialFromPresetRef(node.materialPreset) const resolvedMaterial = node.material
const sourceMaterial = presetMaterial ?? (node.material ? createMaterial(node.material) : DEFAULT_SLAB_MATERIAL) const resolvedMaterialPreset = node.materialPreset
const slabMaterial = sourceMaterial.clone() const cacheKey = JSON.stringify({
material: resolvedMaterial ?? null,
materialPreset: resolvedMaterialPreset ?? null,
})
// Slabs participate in the WebGPU MRT scene pass. Keeping them opaque avoids return getSlabMaterial(cacheKey, {
// pipeline variants that can fail when geometry is regenerated while a material: resolvedMaterial,
// transparent/custom material is attached. materialPreset: resolvedMaterialPreset,
slabMaterial.transparent = false })
slabMaterial.opacity = 1
slabMaterial.alphaMap = null
slabMaterial.side = THREE.DoubleSide
slabMaterial.depthWrite = true
slabMaterial.needsUpdate = true
return slabMaterial
}, [ }, [
node.material, node.material,
node.material?.preset, node.material?.preset,
@@ -40,12 +72,6 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => {
node.materialPreset, node.materialPreset,
]) ])
useEffect(() => {
return () => {
material.dispose()
}
}, [material])
return ( return (
<mesh <mesh
castShadow castShadow
@@ -1,8 +1,14 @@
import { type AnyNodeId, type StairNode, type StairSegmentNode, useRegistry, useScene } from '@pascal-app/core' import {
import { useLayoutEffect, useMemo, useRef } from 'react' type AnyNodeId,
import type * as THREE from 'three' type StairNode,
type StairSegmentNode,
useRegistry,
useScene,
} from '@pascal-app/core'
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events' import { useNodeEvents } from '../../../hooks/use-node-events'
import { createMaterial, createMaterialFromPresetRef, DEFAULT_STAIR_MATERIAL } from '../../../lib/materials' import { getStraightStairSegmentBodyMaterials } from '../../../systems/stair/stair-materials'
export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => { export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => {
const ref = useRef<THREE.Mesh>(null!) const ref = useRef<THREE.Mesh>(null!)
@@ -15,42 +21,37 @@ export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => {
}, [node.id]) }, [node.id])
const handlers = useNodeEvents(node, 'stair-segment') const handlers = useNodeEvents(node, 'stair-segment')
const parentNode = const parentNode = node.parentId
node.parentId ? (nodes[node.parentId as AnyNodeId] as StairNode | undefined) : undefined ? (nodes[node.parentId as AnyNodeId] as StairNode | undefined)
: undefined
const material = useMemo(
() => getStraightStairSegmentBodyMaterials(node, parentNode),
[node, parentNode],
)
const material = useMemo(() => { const placeholderGeometry = useMemo(() => {
const effectiveMaterialPreset = node.materialPreset ?? parentNode?.materialPreset const geometry = new THREE.BufferGeometry()
const effectiveMaterial = node.material ?? parentNode?.material geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
geometry.addGroup(0, 0, 0)
geometry.addGroup(0, 0, 1)
return geometry
}, [])
const presetMaterial = createMaterialFromPresetRef(effectiveMaterialPreset) useEffect(() => {
if (presetMaterial) return presetMaterial return () => {
const mat = effectiveMaterial placeholderGeometry.dispose()
if (!mat) return DEFAULT_STAIR_MATERIAL }
return createMaterial(mat) }, [placeholderGeometry])
}, [
node.materialPreset,
node.material,
node.material?.preset,
node.material?.properties,
node.material?.texture,
parentNode?.materialPreset,
parentNode?.material,
parentNode?.material?.preset,
parentNode?.material?.properties,
parentNode?.material?.texture,
])
return ( return (
<mesh <mesh
geometry={placeholderGeometry}
material={material} material={material}
position={node.position} position={node.position}
ref={ref} ref={ref}
rotation-y={node.rotation} rotation-y={node.rotation}
visible={node.visible} visible={node.visible}
{...handlers} {...handlers}
> />
{/* StairSystem will replace this geometry in the next frame */}
<boxGeometry args={[0, 0, 0]} />
</mesh>
) )
} }
@@ -5,7 +5,7 @@ import {
useRegistry, useRegistry,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useLayoutEffect, useMemo, useRef } from 'react' import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events' import { useNodeEvents } from '../../../hooks/use-node-events'
import { import {
@@ -13,6 +13,11 @@ import {
createMaterialFromPresetRef, createMaterialFromPresetRef,
DEFAULT_STAIR_MATERIAL, DEFAULT_STAIR_MATERIAL,
} from '../../../lib/materials' } from '../../../lib/materials'
import {
getStairBodyMaterials,
getStairRailingMaterial,
type StairBodyMaterials,
} from '../../../systems/stair/stair-materials'
import { NodeRenderer } from '../node-renderer' import { NodeRenderer } from '../node-renderer'
type SegmentTransform = { type SegmentTransform = {
@@ -71,6 +76,24 @@ export const StairRenderer = ({ node }: { node: StairNode }) => {
node.material?.texture, node.material?.texture,
]) ])
const straightBodyMaterials = useMemo(() => getStairBodyMaterials(node), [node])
const railingMaterial = useMemo(() => getStairRailingMaterial(node), [node])
const straightPlaceholderGeometry = useMemo(() => {
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
geometry.addGroup(0, 0, 0)
geometry.addGroup(0, 0, 1)
return geometry
}, [])
useEffect(() => {
return () => {
straightPlaceholderGeometry.dispose()
}
}, [straightPlaceholderGeometry])
return ( return (
<group <group
position-x={node.position[0]} position-x={node.position[0]}
@@ -81,12 +104,18 @@ export const StairRenderer = ({ node }: { node: StairNode }) => {
{...handlers} {...handlers}
> >
{isSegmentBasedStair ? ( {isSegmentBasedStair ? (
<mesh castShadow material={material} name="merged-stair" receiveShadow> <mesh
<boxGeometry args={[0, 0, 0]} /> castShadow
</mesh> geometry={straightPlaceholderGeometry}
material={straightBodyMaterials}
name="merged-stair"
receiveShadow
/>
) : null} ) : null}
{!isSegmentBasedStair ? <CurvedStairBody material={material} stair={node} /> : null} {!isSegmentBasedStair ? (
<StairRailings material={material} stair={node} /> <CurvedStairBody bodyMaterials={straightBodyMaterials} stair={node} />
) : null}
<StairRailings material={railingMaterial} stair={node} />
{isSegmentBasedStair ? ( {isSegmentBasedStair ? (
<group name="segments-wrapper" visible={false}> <group name="segments-wrapper" visible={false}>
{(node.children ?? []).map((childId) => ( {(node.children ?? []).map((childId) => (
@@ -170,6 +199,7 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE.
geometry={BALUSTER_GEOMETRY} geometry={BALUSTER_GEOMETRY}
key={`${stair.id}-curved-baluster-${sideIndex}-${pointIndex}`} key={`${stair.id}-curved-baluster-${sideIndex}-${pointIndex}`}
material={material} material={material}
name="stair-railing-baluster"
position={[point[0], point[1] + railHeight / 2, point[2]]} position={[point[0], point[1] + railHeight / 2, point[2]]}
receiveShadow receiveShadow
scale={[balusterRadius, railHeight, balusterRadius]} scale={[balusterRadius, railHeight, balusterRadius]}
@@ -227,6 +257,7 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE.
geometry={BALUSTER_GEOMETRY} geometry={BALUSTER_GEOMETRY}
key={`${segmentPath.layout.segment.id}-${sidePath.side}-baluster-${pointIndex}`} key={`${segmentPath.layout.segment.id}-${sidePath.side}-baluster-${pointIndex}`}
material={material} material={material}
name="stair-railing-baluster"
position={[point[2], point[1] + railHeight / 2, point[0]]} position={[point[2], point[1] + railHeight / 2, point[0]]}
receiveShadow receiveShadow
scale={[balusterRadius, railHeight, balusterRadius]} scale={[balusterRadius, railHeight, balusterRadius]}
@@ -333,6 +364,8 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE.
const BALUSTER_GEOMETRY = new THREE.CylinderGeometry(1, 1, 1, 8) const BALUSTER_GEOMETRY = new THREE.CylinderGeometry(1, 1, 1, 8)
const RAIL_GEOMETRY = new THREE.CylinderGeometry(1, 1, 1, 8) const RAIL_GEOMETRY = new THREE.CylinderGeometry(1, 1, 1, 8)
const STAIR_TREAD_MATERIAL_INDEX = 0
const STAIR_SIDE_MATERIAL_INDEX = 1
function RailSegment({ function RailSegment({
start, start,
@@ -367,6 +400,7 @@ function RailSegment({
castShadow castShadow
geometry={RAIL_GEOMETRY} geometry={RAIL_GEOMETRY}
material={material} material={material}
name="stair-railing-rail"
position={[midpoint.x, midpoint.y, midpoint.z]} position={[midpoint.x, midpoint.y, midpoint.z]}
quaternion={quaternion} quaternion={quaternion}
receiveShadow receiveShadow
@@ -375,7 +409,14 @@ function RailSegment({
) )
} }
function CurvedStairBody({ stair, material }: { stair: StairNode; material: THREE.Material }) { function CurvedStairBody({
stair,
bodyMaterials,
}: {
stair: StairNode
bodyMaterials: StairBodyMaterials
}) {
const sideMaterial = bodyMaterials[1]
const stepCount = Math.max(2, Math.round(stair.stepCount ?? 10)) const stepCount = Math.max(2, Math.round(stair.stepCount ?? 10))
const totalRise = Math.max(stair.totalRise ?? 2.5, 0.1) const totalRise = Math.max(stair.totalRise ?? 2.5, 0.1)
const stepHeight = totalRise / stepCount const stepHeight = totalRise / stepCount
@@ -411,7 +452,8 @@ function CurvedStairBody({ stair, material }: { stair: StairNode; material: THRE
<mesh <mesh
castShadow castShadow
receiveShadow receiveShadow
material={material} material={sideMaterial}
name="stair-side"
position={[0, spiralColumnHeight / 2, 0]} position={[0, spiralColumnHeight / 2, 0]}
> >
<cylinderGeometry <cylinderGeometry
@@ -443,7 +485,8 @@ function CurvedStairBody({ stair, material }: { stair: StairNode; material: THRE
{isSpiral && (stair.showStepSupports ?? true) ? ( {isSpiral && (stair.showStepSupports ?? true) ? (
<mesh <mesh
castShadow castShadow
material={material} material={sideMaterial}
name="stair-side"
position={[ position={[
Math.cos(midAngle) * Math.cos(midAngle) *
(spiralColumnRadius + (spiralColumnRadius +
@@ -470,7 +513,7 @@ function CurvedStairBody({ stair, material }: { stair: StairNode; material: THRE
<CurvedStepMesh <CurvedStepMesh
endAngle={endAngle} endAngle={endAngle}
innerRadius={innerRadius} innerRadius={innerRadius}
material={material} material={bodyMaterials}
outerRadius={outerRadius} outerRadius={outerRadius}
positionY={0} positionY={0}
startAngle={startAngle} startAngle={startAngle}
@@ -484,7 +527,7 @@ function CurvedStairBody({ stair, material }: { stair: StairNode; material: THRE
<CurvedStepMesh <CurvedStepMesh
endAngle={sweepAngle / 2 + spiralLandingSweep} endAngle={sweepAngle / 2 + spiralLandingSweep}
innerRadius={innerRadius} innerRadius={innerRadius}
material={material} material={bodyMaterials}
outerRadius={outerRadius} outerRadius={outerRadius}
positionY={spiralLastStepTop} positionY={spiralLastStepTop}
startAngle={sweepAngle / 2} startAngle={sweepAngle / 2}
@@ -513,7 +556,7 @@ function CurvedStepMesh({
stepHeight: number stepHeight: number
thickness: number thickness: number
positionY: number positionY: number
material: THREE.Material material: THREE.Material | THREE.Material[]
}) { }) {
const geometry = useMemo( const geometry = useMemo(
() => () =>
@@ -556,15 +599,39 @@ function buildCurvedStepGeometry(
const positions: number[] = [] const positions: number[] = []
const normals: number[] = [] const normals: number[] = []
const uvs: number[] = []
const triangleMaterialIndices: number[] = []
const pointOnArc = (radius: number, angle: number, y: number) => const pointOnArc = (radius: number, angle: number, y: number) =>
new THREE.Vector3(Math.cos(angle) * radius, y, Math.sin(angle) * radius) new THREE.Vector3(Math.cos(angle) * radius, y, Math.sin(angle) * radius)
const pushUv = (point: THREE.Vector3, normal: THREE.Vector3, materialIndex: number) => {
if (materialIndex === STAIR_TREAD_MATERIAL_INDEX) {
const angle = Math.atan2(point.z, point.x)
const arcOffset = (angle - startAngle) * Math.max((innerRadius + outerRadius) * 0.5, 0.01)
uvs.push(arcOffset, Math.sqrt(point.x * point.x + point.z * point.z) - innerRadius)
return
}
const absX = Math.abs(normal.x)
const absY = Math.abs(normal.y)
const absZ = Math.abs(normal.z)
if (absY >= absX && absY >= absZ) {
uvs.push(point.x, point.z)
} else if (absX >= absZ) {
uvs.push(point.z, point.y)
} else {
uvs.push(point.x, point.y)
}
}
const pushTriangle = ( const pushTriangle = (
a: THREE.Vector3, a: THREE.Vector3,
b: THREE.Vector3, b: THREE.Vector3,
c: THREE.Vector3, c: THREE.Vector3,
normal: THREE.Vector3, normal: THREE.Vector3,
materialIndex: number,
) => { ) => {
const edgeAB = b.clone().sub(a) const edgeAB = b.clone().sub(a)
const edgeAC = c.clone().sub(a) const edgeAC = c.clone().sub(a)
@@ -573,7 +640,9 @@ function buildCurvedStepGeometry(
for (const point of ordered) { for (const point of ordered) {
positions.push(point.x, point.y, point.z) positions.push(point.x, point.y, point.z)
normals.push(normal.x, normal.y, normal.z) normals.push(normal.x, normal.y, normal.z)
pushUv(point, normal, materialIndex)
} }
triangleMaterialIndices.push(materialIndex)
} }
const pushQuad = ( const pushQuad = (
@@ -582,9 +651,10 @@ function buildCurvedStepGeometry(
c: THREE.Vector3, c: THREE.Vector3,
d: THREE.Vector3, d: THREE.Vector3,
normal: THREE.Vector3, normal: THREE.Vector3,
materialIndex: number,
) => { ) => {
pushTriangle(a, b, c, normal) pushTriangle(a, b, c, normal, materialIndex)
pushTriangle(a, c, d, normal) pushTriangle(a, c, d, normal, materialIndex)
} }
const upNormal = new THREE.Vector3(0, 1, 0) const upNormal = new THREE.Vector3(0, 1, 0)
@@ -609,10 +679,38 @@ function buildCurvedStepGeometry(
const outerNormal = new THREE.Vector3(Math.cos(midAngle), 0, Math.sin(midAngle)).normalize() const outerNormal = new THREE.Vector3(Math.cos(midAngle), 0, Math.sin(midAngle)).normalize()
const innerNormal = new THREE.Vector3(-Math.cos(midAngle), 0, -Math.sin(midAngle)).normalize() const innerNormal = new THREE.Vector3(-Math.cos(midAngle), 0, -Math.sin(midAngle)).normalize()
pushQuad(innerStartTop, outerStartTop, outerEndTop, innerEndTop, upNormal) pushQuad(
pushQuad(innerStartBottom, innerEndBottom, outerEndBottom, outerStartBottom, downNormal) innerStartTop,
pushQuad(innerStartBottom, innerStartTop, innerEndTop, innerEndBottom, innerNormal) outerStartTop,
pushQuad(outerStartBottom, outerEndBottom, outerEndTop, outerStartTop, outerNormal) outerEndTop,
innerEndTop,
upNormal,
STAIR_TREAD_MATERIAL_INDEX,
)
pushQuad(
innerStartBottom,
innerEndBottom,
outerEndBottom,
outerStartBottom,
downNormal,
STAIR_SIDE_MATERIAL_INDEX,
)
pushQuad(
innerStartBottom,
innerStartTop,
innerEndTop,
innerEndBottom,
innerNormal,
STAIR_SIDE_MATERIAL_INDEX,
)
pushQuad(
outerStartBottom,
outerEndBottom,
outerEndTop,
outerStartTop,
outerNormal,
STAIR_SIDE_MATERIAL_INDEX,
)
} }
const startInnerBottom = pointOnArc(innerRadius, startAngle, y0) const startInnerBottom = pointOnArc(innerRadius, startAngle, y0)
@@ -634,12 +732,49 @@ function buildCurvedStepGeometry(
sweepDirection * Math.cos(endAngle), sweepDirection * Math.cos(endAngle),
).normalize() ).normalize()
pushQuad(startInnerBottom, startOuterBottom, startOuterTop, startInnerTop, startNormal) pushQuad(
pushQuad(endInnerBottom, endInnerTop, endOuterTop, endOuterBottom, endNormal) startInnerBottom,
startOuterBottom,
startOuterTop,
startInnerTop,
startNormal,
STAIR_SIDE_MATERIAL_INDEX,
)
pushQuad(
endInnerBottom,
endInnerTop,
endOuterTop,
endOuterBottom,
endNormal,
STAIR_SIDE_MATERIAL_INDEX,
)
const geometry = new THREE.BufferGeometry() const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3)) geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3))
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
geometry.clearGroups()
let currentMaterial = triangleMaterialIndices[0]
let groupStart = 0
for (let triangleIndex = 1; triangleIndex < triangleMaterialIndices.length; triangleIndex++) {
const materialIndex = triangleMaterialIndices[triangleIndex]
if (materialIndex === currentMaterial) continue
geometry.addGroup(groupStart * 3, (triangleIndex - groupStart) * 3, currentMaterial)
groupStart = triangleIndex
currentMaterial = materialIndex
}
if (triangleMaterialIndices.length > 0) {
geometry.addGroup(
groupStart * 3,
(triangleMaterialIndices.length - groupStart) * 3,
currentMaterial ?? STAIR_SIDE_MATERIAL_INDEX,
)
}
geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(uvs.slice(), 2))
geometry.computeVertexNormals() geometry.computeVertexNormals()
return geometry return geometry
} }
@@ -1,12 +1,8 @@
import { useRegistry, useScene, type WallNode } from '@pascal-app/core' import { useRegistry, useScene, type WallNode } from '@pascal-app/core'
import { useLayoutEffect, useMemo, useRef } from 'react' import { useLayoutEffect, useRef } from 'react'
import type { Mesh } from 'three' import type { Mesh } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events' import { useNodeEvents } from '../../../hooks/use-node-events'
import { import { getVisibleWallMaterials } from '../../../systems/wall/wall-materials'
createMaterial,
createMaterialFromPresetRef,
DEFAULT_WALL_MATERIAL,
} from '../../../lib/materials'
import { NodeRenderer } from '../node-renderer' import { NodeRenderer } from '../node-renderer'
export const WallRenderer = ({ node }: { node: WallNode }) => { export const WallRenderer = ({ node }: { node: WallNode }) => {
@@ -19,20 +15,7 @@ export const WallRenderer = ({ node }: { node: WallNode }) => {
}, [node.id]) }, [node.id])
const handlers = useNodeEvents(node, 'wall') const handlers = useNodeEvents(node, 'wall')
const material = getVisibleWallMaterials(node)
const material = useMemo(() => {
const presetMaterial = createMaterialFromPresetRef(node.materialPreset)
if (presetMaterial) return presetMaterial
const mat = node.material
if (!mat) return DEFAULT_WALL_MATERIAL
return createMaterial(mat)
}, [
node.material,
node.material?.preset,
node.material?.properties,
node.material?.texture,
node.materialPreset,
])
return ( return (
<mesh castShadow material={material} receiveShadow ref={ref} visible={node.visible}> <mesh castShadow material={material} receiveShadow ref={ref} visible={node.visible}>
@@ -38,7 +38,15 @@ export const GroundOccluder = () => {
const polygons: [number, number][][] = [] const polygons: [number, number][][] = []
Object.values(nodes).forEach((node) => { Object.values(nodes).forEach((node) => {
if (!(node.type === 'slab' && node.visible && node.polygon.length >= 3)) { if (
!(
node.type === 'slab' &&
node.visible &&
node.polygon.length >= 3 &&
// Only recessed slabs should punch through the ground plane.
(node.elevation ?? 0.05) < 0
)
) {
return return
} }
@@ -26,7 +26,7 @@ import { SceneRenderer } from '../renderers/scene-renderer'
import FrameLimiter from './frame-limiter' import FrameLimiter from './frame-limiter'
import { Lights } from './lights' import { Lights } from './lights'
import { PerfMonitor } from './perf-monitor' import { PerfMonitor } from './perf-monitor'
import PostProcessing from './post-processing' import PostProcessing, { DEFAULT_HOVER_STYLES, type HoverStyles } from './post-processing'
import { SelectionManager } from './selection-manager' import { SelectionManager } from './selection-manager'
import { ViewerCamera } from './viewer-camera' import { ViewerCamera } from './viewer-camera'
@@ -101,12 +101,14 @@ function GPUDeviceWatcher() {
interface ViewerProps { interface ViewerProps {
children?: React.ReactNode children?: React.ReactNode
hoverStyles?: HoverStyles
selectionManager?: 'default' | 'custom' selectionManager?: 'default' | 'custom'
perf?: boolean perf?: boolean
} }
const Viewer: React.FC<ViewerProps> = ({ const Viewer: React.FC<ViewerProps> = ({
children, children,
hoverStyles = DEFAULT_HOVER_STYLES,
selectionManager = 'default', selectionManager = 'default',
perf = false, perf = false,
}) => { }) => {
@@ -165,7 +167,7 @@ const Viewer: React.FC<ViewerProps> = ({
<WallSystem /> <WallSystem />
<WindowSystem /> <WindowSystem />
<ZoneSystem /> <ZoneSystem />
<PostProcessing /> <PostProcessing hoverStyles={hoverStyles} />
{/* <DebugRenderer /> */} {/* <DebugRenderer /> */}
<GPUDeviceWatcher /> <GPUDeviceWatcher />
@@ -47,6 +47,28 @@ const RETRY_DELAY_MS = 500
const DARK_BG = '#1f2433' const DARK_BG = '#1f2433'
const LIGHT_BG = '#ffffff' const LIGHT_BG = '#ffffff'
export type HoverStyle = {
visibleColor: number
hiddenColor: number
strength: number
pulse: boolean
}
export type HoverStyles = {
default: HoverStyle
} & Record<string, HoverStyle>
const DEFAULT_HOVER_STYLE: HoverStyle = {
visibleColor: 0x00_aa_ff,
hiddenColor: 0xf3_ff_47,
strength: 5,
pulse: true,
}
export const DEFAULT_HOVER_STYLES: HoverStyles = {
default: DEFAULT_HOVER_STYLE,
}
function sanitizeOutlineObjects(objects: Object3D[]) { function sanitizeOutlineObjects(objects: Object3D[]) {
let nextIndex = 0 let nextIndex = 0
@@ -62,8 +84,12 @@ function sanitizeOutlineObjects(objects: Object3D[]) {
objects.length = nextIndex objects.length = nextIndex
} }
const PostProcessingPasses = () => { const PostProcessingPasses = ({
const { gl: renderer, scene, camera } = useThree() hoverStyles = DEFAULT_HOVER_STYLES,
}: {
hoverStyles?: HoverStyles
}) => {
const { gl: renderer, invalidate, scene, camera } = useThree()
const renderPipelineRef = useRef<RenderPipeline | null>(null) const renderPipelineRef = useRef<RenderPipeline | null>(null)
const hasPipelineErrorRef = useRef(false) const hasPipelineErrorRef = useRef(false)
const retryCountRef = useRef(0) const retryCountRef = useRef(0)
@@ -83,6 +109,10 @@ const PostProcessingPasses = () => {
return l return l
}, []) }, [])
const hoverHighlightMode = useViewer((s) => s.hoverHighlightMode) const hoverHighlightMode = useViewer((s) => s.hoverHighlightMode)
const hoverVisibleColor = useMemo(() => uniform(new Color(DEFAULT_HOVER_STYLE.visibleColor)), [])
const hoverHiddenColor = useMemo(() => uniform(new Color(DEFAULT_HOVER_STYLE.hiddenColor)), [])
const hoverStrength = useMemo(() => uniform(DEFAULT_HOVER_STYLE.strength), [])
const hoverPulseMix = useMemo(() => uniform(DEFAULT_HOVER_STYLE.pulse ? 0 : 1), [])
// Subscribe to projectId so the pipeline rebuilds on project switch // Subscribe to projectId so the pipeline rebuilds on project switch
const projectId = useViewer((s) => s.projectId) const projectId = useViewer((s) => s.projectId)
@@ -119,6 +149,23 @@ const PostProcessingPasses = () => {
} }
}, []) }, [])
useEffect(() => {
const style = hoverStyles[hoverHighlightMode] ?? hoverStyles.default
hoverVisibleColor.value.setHex(style.visibleColor)
hoverHiddenColor.value.setHex(style.hiddenColor)
hoverStrength.value = style.strength
hoverPulseMix.value = style.pulse ? 0 : 1
invalidate()
}, [
hoverHiddenColor,
hoverHighlightMode,
hoverPulseMix,
hoverStrength,
hoverStyles,
hoverVisibleColor,
invalidate,
])
// Build / rebuild the post-processing pipeline // Build / rebuild the post-processing pipeline
useEffect(() => { useEffect(() => {
// Intentionally touch these so React/biome treat project switches and retry bumps // Intentionally touch these so React/biome treat project switches and retry bumps
@@ -248,18 +295,9 @@ const PostProcessingPasses = () => {
.mul(selectedStrength) .mul(selectedStrength)
// Hovered: blue visible, yellow hidden, pulsing // Hovered: blue visible, yellow hidden, pulsing
const hoverVisibleColor = uniform(
new Color(hoverHighlightMode === 'delete' ? 0xef_44_44 : 0x00_aa_ff),
)
const hoverHiddenColor = uniform(
new Color(hoverHighlightMode === 'delete' ? 0x99_1b_1b : 0xf3_ff_47),
)
const hoverStrength = uniform(hoverHighlightMode === 'delete' ? 6 : 5)
const pulsePeriod = uniform(3) const pulsePeriod = uniform(3)
const osc = const oscillating = oscSine(time.div(pulsePeriod).mul(2)).mul(0.5).add(0.5)
hoverHighlightMode === 'delete' const osc = mix(oscillating, float(1), hoverPulseMix)
? float(1)
: oscSine(time.div(pulsePeriod).mul(2)).mul(0.5).add(0.5) // [ 0.5, 1.0 ]
const hoverOutline = outlineNode.secondaryVisibleEdge const hoverOutline = outlineNode.secondaryVisibleEdge
.mul(hoverVisibleColor) .mul(hoverVisibleColor)
.add(outlineNode.secondaryHiddenEdge.mul(hoverHiddenColor)) .add(outlineNode.secondaryHiddenEdge.mul(hoverHiddenColor))
@@ -298,7 +336,18 @@ const PostProcessingPasses = () => {
} }
renderPipelineRef.current = null renderPipelineRef.current = null
} }
}, [renderer, scene, camera, hoverHighlightMode, zoneLayers, projectId, pipelineVersion]) }, [
camera,
hoverHiddenColor,
hoverPulseMix,
hoverStrength,
hoverVisibleColor,
pipelineVersion,
projectId,
renderer,
scene,
zoneLayers,
])
useFrame((_, delta) => { useFrame((_, delta) => {
// Animate background colour toward the current theme target (same lerp as AnimatedBackground) // Animate background colour toward the current theme target (same lerp as AnimatedBackground)
@@ -64,6 +64,8 @@ export function useNodeEvents<T extends NodeType>(node: NodeConfig[T]['node'], t
position: [e.point.x, e.point.y, e.point.z], position: [e.point.x, e.point.y, e.point.z],
localPosition: [localPoint.x, localPoint.y, localPoint.z], localPosition: [localPoint.x, localPoint.y, localPoint.z],
normal: e.face ? [e.face.normal.x, e.face.normal.y, e.face.normal.z] : undefined, normal: e.face ? [e.face.normal.x, e.face.normal.y, e.face.normal.z] : undefined,
faceIndex: e.faceIndex ?? undefined,
object: e.object,
stopPropagation: () => e.stopPropagation(), stopPropagation: () => e.stopPropagation(),
nativeEvent: e, nativeEvent: e,
} as NodeConfig[T]['event'] } as NodeConfig[T]['event']
+10 -1
View File
@@ -1,12 +1,18 @@
export { default as Viewer } from './components/viewer' export { default as Viewer } from './components/viewer'
export { SSGI_PARAMS } from './components/viewer/post-processing' export type { HoverStyle, HoverStyles } from './components/viewer/post-processing'
export {
DEFAULT_HOVER_STYLES,
SSGI_PARAMS,
} from './components/viewer/post-processing'
export { WalkthroughControls } from './components/viewer/walkthrough-controls' export { WalkthroughControls } from './components/viewer/walkthrough-controls'
export { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url' export { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url'
export { SCENE_LAYER, ZONE_LAYER } from './lib/layers' export { SCENE_LAYER, ZONE_LAYER } from './lib/layers'
export { export {
applyMaterialPresetToMaterials,
clearMaterialCache, clearMaterialCache,
createDefaultMaterial, createDefaultMaterial,
createMaterial, createMaterial,
createMaterialFromPresetRef,
DEFAULT_CEILING_MATERIAL, DEFAULT_CEILING_MATERIAL,
DEFAULT_DOOR_MATERIAL, DEFAULT_DOOR_MATERIAL,
DEFAULT_ROOF_MATERIAL, DEFAULT_ROOF_MATERIAL,
@@ -19,3 +25,6 @@ export { mergedOutline } from './lib/merged-outline-node'
export { default as useViewer } from './store/use-viewer' export { default as useViewer } from './store/use-viewer'
export { InteractiveSystem } from './systems/interactive/interactive-system' export { InteractiveSystem } from './systems/interactive/interactive-system'
export { snapLevelsToTruePositions } from './systems/level/level-utils' export { snapLevelsToTruePositions } from './systems/level/level-utils'
export { getRoofMaterialArray } from './systems/roof/roof-materials'
export { getStairBodyMaterials, getStairRailingMaterial } from './systems/stair/stair-materials'
export { getVisibleWallMaterials } from './systems/wall/wall-materials'
+9 -3
View File
@@ -1,4 +1,10 @@
import type { AnyNode, BaseNode, BuildingNode, LevelNode, ZoneNode } from '@pascal-app/core' import type {
AnyNode,
BaseNode,
BuildingNode,
LevelNode,
ZoneNode,
} from '@pascal-app/core'
import type { Object3D } from 'three' import type { Object3D } from 'three'
type SelectionPath = { type SelectionPath = {
buildingId: BuildingNode['id'] | null buildingId: BuildingNode['id'] | null
@@ -14,8 +20,8 @@ type ViewerState = {
selection: SelectionPath selection: SelectionPath
previewSelectedIds: BaseNode['id'][] previewSelectedIds: BaseNode['id'][]
setPreviewSelectedIds: (ids: BaseNode['id'][]) => void setPreviewSelectedIds: (ids: BaseNode['id'][]) => void
hoverHighlightMode: 'default' | 'delete' hoverHighlightMode: string
setHoverHighlightMode: (mode: 'default' | 'delete') => void setHoverHighlightMode: (mode: string) => void
hoveredId: AnyNode['id'] | ZoneNode['id'] | null hoveredId: AnyNode['id'] | ZoneNode['id'] | null
setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void
cameraMode: 'perspective' | 'orthographic' cameraMode: 'perspective' | 'orthographic'
+5 -4
View File
@@ -22,8 +22,8 @@ type ViewerState = {
selection: SelectionPath selection: SelectionPath
previewSelectedIds: BaseNode['id'][] previewSelectedIds: BaseNode['id'][]
setPreviewSelectedIds: (ids: BaseNode['id'][]) => void setPreviewSelectedIds: (ids: BaseNode['id'][]) => void
hoverHighlightMode: 'default' | 'delete' hoverHighlightMode: string
setHoverHighlightMode: (mode: 'default' | 'delete') => void setHoverHighlightMode: (mode: string) => void
hoveredId: AnyNode['id'] | ZoneNode['id'] | null hoveredId: AnyNode['id'] | ZoneNode['id'] | null
setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void
@@ -85,9 +85,10 @@ const useViewer = create<ViewerState>()(
previewSelectedIds: [], previewSelectedIds: [],
setPreviewSelectedIds: (ids) => set({ previewSelectedIds: ids }), setPreviewSelectedIds: (ids) => set({ previewSelectedIds: ids }),
hoverHighlightMode: 'default', hoverHighlightMode: 'default',
setHoverHighlightMode: (mode) => set({ hoverHighlightMode: mode }), setHoverHighlightMode: (mode) =>
set((state) => (state.hoverHighlightMode === mode ? state : { hoverHighlightMode: mode })),
hoveredId: null, hoveredId: null,
setHoveredId: (id) => set({ hoveredId: id }), setHoveredId: (id) => set((state) => (state.hoveredId === id ? state : { hoveredId: id })),
cameraMode: 'perspective', cameraMode: 'perspective',
setCameraMode: (mode) => set({ cameraMode: mode }), setCameraMode: (mode) => set({ cameraMode: mode }),
@@ -0,0 +1,67 @@
import {
getEffectiveRoofSurfaceMaterial,
type RoofNode,
type RoofSegmentNode,
} from '@pascal-app/core'
import * as THREE from 'three'
import { createMaterial, createMaterialFromPresetRef } from '../../lib/materials'
export type RoofMaterialArray = [THREE.Material, THREE.Material, THREE.Material, THREE.Material]
const roofMaterialArrayCache = new Map<string, RoofMaterialArray>()
function getSurfaceMaterialSignature(
spec: ReturnType<typeof getEffectiveRoofSurfaceMaterial>,
): string {
return JSON.stringify({
material: spec.material ?? null,
materialPreset: spec.materialPreset ?? null,
})
}
function createResolvedMaterial(
material: RoofNode['material'] | RoofSegmentNode['material'] | undefined,
materialPreset: string | undefined,
): THREE.Material | null {
if (materialPreset) {
return createMaterialFromPresetRef(materialPreset)
}
if (material) {
return createMaterial(material)
}
return null
}
export function getRoofMaterialArray(node: RoofNode): RoofMaterialArray | null {
const top = getEffectiveRoofSurfaceMaterial(node, 'top')
const edge = getEffectiveRoofSurfaceMaterial(node, 'edge')
const wall = getEffectiveRoofSurfaceMaterial(node, 'wall')
const cacheKey = JSON.stringify({
top: getSurfaceMaterialSignature(top),
edge: getSurfaceMaterialSignature(edge),
wall: getSurfaceMaterialSignature(wall),
})
const cached = roofMaterialArrayCache.get(cacheKey)
if (cached) return cached
const topMaterial = createResolvedMaterial(top.material, top.materialPreset)
const edgeMaterial = createResolvedMaterial(edge.material, edge.materialPreset)
const wallMaterial = createResolvedMaterial(wall.material, wall.materialPreset)
if (!(topMaterial || edgeMaterial || wallMaterial)) {
return null
}
const materialArray: RoofMaterialArray = [
edgeMaterial ?? wallMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(),
wallMaterial ?? edgeMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(),
wallMaterial ?? edgeMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(),
topMaterial ?? wallMaterial ?? edgeMaterial ?? new THREE.MeshStandardMaterial(),
]
roofMaterialArrayCache.set(cacheKey, materialArray)
return materialArray
}
@@ -0,0 +1,87 @@
import {
getEffectiveStairSurfaceMaterial,
type StairNode,
type StairSegmentNode,
} from '@pascal-app/core'
import type * as THREE from 'three'
import {
createMaterial,
createMaterialFromPresetRef,
DEFAULT_STAIR_MATERIAL,
} from '../../lib/materials'
export type StairBodyMaterials = [THREE.Material, THREE.Material]
const stairBodyMaterialCache = new Map<string, StairBodyMaterials>()
const stairRailingMaterialCache = new Map<string, THREE.Material>()
function getSurfaceMaterialSignature(
spec: ReturnType<typeof getEffectiveStairSurfaceMaterial>,
): string {
return JSON.stringify({
material: spec.material ?? null,
materialPreset: spec.materialPreset ?? null,
})
}
function createResolvedMaterial(
material: StairNode['material'] | StairSegmentNode['material'] | undefined,
materialPreset: string | undefined,
): THREE.Material {
if (materialPreset) {
return createMaterialFromPresetRef(materialPreset) ?? DEFAULT_STAIR_MATERIAL
}
if (material) {
return createMaterial(material)
}
return DEFAULT_STAIR_MATERIAL
}
export function getStairBodyMaterials(stair: StairNode): StairBodyMaterials {
const tread = getEffectiveStairSurfaceMaterial(stair, 'tread')
const side = getEffectiveStairSurfaceMaterial(stair, 'side')
const cacheKey = JSON.stringify({
tread: getSurfaceMaterialSignature(tread),
side: getSurfaceMaterialSignature(side),
})
const cached = stairBodyMaterialCache.get(cacheKey)
if (cached) return cached
const materials: StairBodyMaterials = [
createResolvedMaterial(tread.material, tread.materialPreset),
createResolvedMaterial(side.material, side.materialPreset),
]
stairBodyMaterialCache.set(cacheKey, materials)
return materials
}
export function getStairRailingMaterial(stair: StairNode): THREE.Material {
const railing = getEffectiveStairSurfaceMaterial(stair, 'railing')
const cacheKey = getSurfaceMaterialSignature(railing)
const cached = stairRailingMaterialCache.get(cacheKey)
if (cached) return cached
const material = createResolvedMaterial(railing.material, railing.materialPreset)
stairRailingMaterialCache.set(cacheKey, material)
return material
}
export function getStraightStairSegmentBodyMaterials(
segment: StairSegmentNode,
parentNode?: StairNode,
): StairBodyMaterials {
if (segment.material !== undefined || typeof segment.materialPreset === 'string') {
const override = createResolvedMaterial(segment.material, segment.materialPreset)
return [override, override]
}
if (parentNode) {
return getStairBodyMaterials(parentNode)
}
return [DEFAULT_STAIR_MATERIAL, DEFAULT_STAIR_MATERIAL]
}
@@ -1,210 +1,14 @@
import { import { type AnyNodeId, emitter, sceneRegistry, useScene, type WallNode } from '@pascal-app/core'
type AnyNodeId,
baseMaterial,
emitter,
getMaterialPresetByRef,
sceneRegistry,
useScene,
type WallNode,
} from '@pascal-app/core'
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import type { Material } from 'three' import type { Material } from 'three'
import { Color } from 'three' import { type Mesh, Vector3 } from 'three/webgpu'
import { Fn, float, fract, length, mix, positionLocal, smoothstep, step, vec2 } from 'three/tsl'
import { type Mesh, MeshStandardNodeMaterial, Vector3 } from 'three/webgpu'
import useViewer from '../../store/use-viewer' import useViewer from '../../store/use-viewer'
import { createMaterial, createMaterialFromPresetRef } from '../../lib/materials' import { getMaterialsForWall } from './wall-materials'
const tmpVec = new Vector3() const tmpVec = new Vector3()
const u = new Vector3() const u = new Vector3()
const v = new Vector3() const v = new Vector3()
const DEFAULT_WALL_COLOR = '#f2f0ed'
const WALL_HIGHLIGHT_PROFILES = {
delete: {
color: new Color('#dc2626'),
blend: 0.76,
emissiveBlend: 0.92,
emissiveIntensity: 0.46,
},
selection: {
color: new Color('#818cf8'),
blend: 0.32,
emissiveBlend: 0.7,
emissiveIntensity: 0.42,
},
} as const
type WallHighlightKind = keyof typeof WALL_HIGHLIGHT_PROFILES
const dotPattern = Fn(() => {
const scale = float(0.1)
const dotSize = float(0.3)
const uv = vec2(positionLocal.x, positionLocal.y).div(scale)
const gridUV = fract(uv)
const dist = length(gridUV.sub(0.5))
const dots = step(dist, dotSize.mul(0.5))
const fadeHeight = float(2.5)
const yFade = float(1).sub(smoothstep(float(0), fadeHeight, positionLocal.y))
return dots.mul(yFade)
})
interface WallMaterials {
visible: Material
invisible: MeshStandardNodeMaterial
deleteVisible: Material
deleteInvisible: MeshStandardNodeMaterial
highlightedVisible: Material
highlightedInvisible: MeshStandardNodeMaterial
materialHash: string
}
const wallMaterialCache = new Map<string, WallMaterials>()
const presetColors = {
white: '#ffffff',
brick: '#8b4513',
concrete: '#808080',
wood: '#deb887',
glass: '#87ceeb',
metal: '#c0c0c0',
plaster: '#f5f5dc',
tile: '#dcdcdc',
marble: '#f5f5f5',
} as const
function getMaterialHash(wallNode: WallNode): string {
if (wallNode.materialPreset) return `preset-ref-${wallNode.materialPreset}`
if (!wallNode.material) return 'none'
const mat = wallNode.material
if (mat.preset && mat.preset !== 'custom') {
return `preset-${mat.preset}`
}
if (mat.properties) {
return `props-${mat.properties.color}-${mat.properties.roughness}-${mat.properties.metalness}`
}
return 'default'
}
function getPresetColor(preset: string): string {
return presetColors[preset as keyof typeof presetColors] ?? '#ffffff'
}
function getHighlightedColor(color: Color, kind: WallHighlightKind): Color {
const profile = WALL_HIGHLIGHT_PROFILES[kind]
return color.clone().lerp(profile.color, profile.blend)
}
function createHighlightedWallMaterial(
material: Material,
kind: WallHighlightKind,
): Material {
const highlightedMaterial = material.clone() as Material & {
color?: Color
emissive?: Color
emissiveIntensity?: number
needsUpdate?: boolean
}
const profile = WALL_HIGHLIGHT_PROFILES[kind]
if ('color' in highlightedMaterial && highlightedMaterial.color) {
highlightedMaterial.color = getHighlightedColor(highlightedMaterial.color, kind)
}
if ('emissive' in highlightedMaterial && highlightedMaterial.emissive) {
highlightedMaterial.emissive = highlightedMaterial.emissive
.clone()
.lerp(profile.color, profile.emissiveBlend)
}
if ('emissiveIntensity' in highlightedMaterial) {
highlightedMaterial.emissiveIntensity = Math.max(
highlightedMaterial.emissiveIntensity ?? 0,
profile.emissiveIntensity,
)
}
highlightedMaterial.needsUpdate = true
return highlightedMaterial
}
function createBaseVisibleWallMaterial(wallNode: WallNode): Material {
if (wallNode.materialPreset) {
return createMaterialFromPresetRef(wallNode.materialPreset) ?? baseMaterial
}
if (wallNode.material) {
return createMaterial(wallNode.material)
}
return baseMaterial
}
function getMaterialsForWall(wallNode: WallNode): WallMaterials {
const cacheKey = wallNode.id
const materialHash = getMaterialHash(wallNode)
const existing = wallMaterialCache.get(cacheKey)
if (existing && existing.materialHash === materialHash) {
return existing
}
if (existing) {
existing.visible.dispose()
existing.invisible.dispose()
existing.deleteVisible.dispose()
existing.deleteInvisible.dispose()
existing.highlightedVisible.dispose()
existing.highlightedInvisible.dispose()
}
let userColor = DEFAULT_WALL_COLOR
const preset = getMaterialPresetByRef(wallNode.materialPreset)
if (preset?.mapProperties?.color) {
userColor = preset.mapProperties.color
} else if (wallNode.material?.properties?.color) {
userColor = wallNode.material.properties.color
} else if (wallNode.material?.preset && wallNode.material.preset !== 'custom') {
userColor = getPresetColor(wallNode.material.preset)
}
const visibleMat = createBaseVisibleWallMaterial(wallNode)
const invisibleMat = new MeshStandardNodeMaterial({
transparent: true,
opacityNode: mix(float(0.0), float(0.24), dotPattern()),
color: userColor,
depthWrite: false,
emissive: userColor,
})
const highlightedVisible = createHighlightedWallMaterial(visibleMat, 'selection')
const highlightedInvisible = createHighlightedWallMaterial(
invisibleMat,
'selection',
) as MeshStandardNodeMaterial
const deleteVisible = createHighlightedWallMaterial(visibleMat, 'delete')
const deleteInvisible = createHighlightedWallMaterial(invisibleMat, 'delete') as MeshStandardNodeMaterial
const result: WallMaterials = {
visible: visibleMat,
invisible: invisibleMat,
deleteVisible,
deleteInvisible,
highlightedVisible,
highlightedInvisible,
materialHash,
}
wallMaterialCache.set(cacheKey, result)
return result
}
function getVisibleWallMaterial(wallNode: WallNode): Material {
return createBaseVisibleWallMaterial(wallNode)
}
function getWallHideState( function getWallHideState(
wallNode: WallNode, wallNode: WallNode,
@@ -301,7 +105,7 @@ export const WallCutout = () => {
? materials.deleteVisible ? materials.deleteVisible
: isSelectionHighlighted : isSelectionHighlighted
? materials.highlightedVisible ? materials.highlightedVisible
: getVisibleWallMaterial(wallNode) : materials.visible
} }
}) })
lastWallMode.current = wallMode lastWallMode.current = wallMode
@@ -311,7 +115,7 @@ export const WallCutout = () => {
}) })
useEffect(() => { useEffect(() => {
const snapshot = new Map<Mesh, Material>() const snapshot = new Map<Mesh, Material | Material[]>()
const restoreForCapture = () => { const restoreForCapture = () => {
sceneRegistry.byType.wall.forEach((wallId) => { sceneRegistry.byType.wall.forEach((wallId) => {
@@ -320,10 +124,10 @@ export const WallCutout = () => {
const wallNode = useScene.getState().nodes[wallId as AnyNodeId] as WallNode | undefined const wallNode = useScene.getState().nodes[wallId as AnyNodeId] as WallNode | undefined
if (!wallNode || wallNode.type !== 'wall') return if (!wallNode || wallNode.type !== 'wall') return
const mats = getMaterialsForWall(wallNode) const mats = getMaterialsForWall(wallNode)
const current = wallMesh.material as Material const current = wallMesh.material as Material | Material[]
snapshot.set(wallMesh, current) snapshot.set(wallMesh, current)
if (current === mats.highlightedVisible || current === mats.deleteVisible) { if (current === mats.highlightedVisible || current === mats.deleteVisible) {
wallMesh.material = getVisibleWallMaterial(wallNode) wallMesh.material = mats.visible
} else if (current === mats.highlightedInvisible || current === mats.deleteInvisible) { } else if (current === mats.highlightedInvisible || current === mats.deleteInvisible) {
wallMesh.material = mats.invisible wallMesh.material = mats.invisible
} }
@@ -0,0 +1,226 @@
import {
baseMaterial,
getEffectiveWallSurfaceMaterial,
getMaterialPresetByRef,
getWallSurfaceMaterialSignature,
resolveMaterial,
type WallNode,
type WallSurfaceMaterialSpec,
} from '@pascal-app/core'
import { Color, type Material } from 'three'
import { Fn, float, fract, length, mix, positionLocal, smoothstep, step, vec2 } from 'three/tsl'
import { MeshStandardNodeMaterial } from 'three/webgpu'
import { createMaterial, createMaterialFromPresetRef } from '../../lib/materials'
const DEFAULT_WALL_COLOR = '#f2f0ed'
const WALL_HIGHLIGHT_PROFILES = {
delete: {
color: new Color('#dc2626'),
blend: 0.76,
emissiveBlend: 0.92,
emissiveIntensity: 0.46,
},
selection: {
color: new Color('#818cf8'),
blend: 0.32,
emissiveBlend: 0.7,
emissiveIntensity: 0.42,
},
} as const
type WallHighlightKind = keyof typeof WALL_HIGHLIGHT_PROFILES
export type WallMaterialArray = [Material, Material, Material]
export interface WallMaterials {
visible: WallMaterialArray
invisible: WallMaterialArray
deleteVisible: WallMaterialArray
deleteInvisible: WallMaterialArray
highlightedVisible: WallMaterialArray
highlightedInvisible: WallMaterialArray
materialHash: string
}
const wallMaterialCache = new Map<string, WallMaterials>()
const dotPattern = Fn(() => {
const scale = float(0.1)
const dotSize = float(0.3)
const uv = vec2(positionLocal.x, positionLocal.y).div(scale)
const gridUV = fract(uv)
const dist = length(gridUV.sub(0.5))
const dots = step(dist, dotSize.mul(0.5))
const fadeHeight = float(2.5)
const yFade = float(1).sub(smoothstep(float(0), fadeHeight, positionLocal.y))
return dots.mul(yFade)
})
function getSurfaceVisibleMaterial(spec: WallSurfaceMaterialSpec): Material {
if (spec.materialPreset) {
return createMaterialFromPresetRef(spec.materialPreset) ?? baseMaterial
}
if (spec.material) {
return createMaterial(spec.material)
}
return baseMaterial
}
function getSurfaceColor(spec: WallSurfaceMaterialSpec, fallback = DEFAULT_WALL_COLOR): string {
const preset = getMaterialPresetByRef(spec.materialPreset)
if (preset?.mapProperties?.color) {
return preset.mapProperties.color
}
if (spec.material) {
return resolveMaterial(spec.material).color
}
return fallback
}
function getHighlightedColor(color: Color, kind: WallHighlightKind): Color {
const profile = WALL_HIGHLIGHT_PROFILES[kind]
return color.clone().lerp(profile.color, profile.blend)
}
function createHighlightedWallMaterial(material: Material, kind: WallHighlightKind): Material {
const highlightedMaterial = material.clone() as Material & {
color?: Color
emissive?: Color
emissiveIntensity?: number
needsUpdate?: boolean
}
const profile = WALL_HIGHLIGHT_PROFILES[kind]
if ('color' in highlightedMaterial && highlightedMaterial.color) {
highlightedMaterial.color = getHighlightedColor(highlightedMaterial.color, kind)
}
if ('emissive' in highlightedMaterial && highlightedMaterial.emissive) {
highlightedMaterial.emissive = highlightedMaterial.emissive
.clone()
.lerp(profile.color, profile.emissiveBlend)
}
if ('emissiveIntensity' in highlightedMaterial) {
highlightedMaterial.emissiveIntensity = Math.max(
highlightedMaterial.emissiveIntensity ?? 0,
profile.emissiveIntensity,
)
}
highlightedMaterial.needsUpdate = true
return highlightedMaterial
}
function createInvisibleWallMaterial(color: string): MeshStandardNodeMaterial {
return new MeshStandardNodeMaterial({
transparent: true,
opacityNode: mix(float(0.0), float(0.24), dotPattern()),
color,
depthWrite: false,
emissive: color,
})
}
function mapWallMaterialArray(
materials: WallMaterialArray,
iteratee: (material: Material, index: number) => Material,
): WallMaterialArray {
return materials.map(iteratee) as WallMaterialArray
}
function disposeOwnedMaterials(materials: WallMaterialArray[]) {
const owned = new Set<Material>()
materials.forEach((entry) => {
entry.forEach((material) => {
owned.add(material)
})
})
owned.forEach((material) => {
material.dispose()
})
}
export function getWallMaterialHash(wallNode: WallNode): string {
return JSON.stringify({
interior: getWallSurfaceMaterialSignature(
getEffectiveWallSurfaceMaterial(wallNode, 'interior'),
),
exterior: getWallSurfaceMaterialSignature(
getEffectiveWallSurfaceMaterial(wallNode, 'exterior'),
),
})
}
export function getMaterialsForWall(wallNode: WallNode): WallMaterials {
const cacheKey = wallNode.id
const materialHash = getWallMaterialHash(wallNode)
const existing = wallMaterialCache.get(cacheKey)
if (existing && existing.materialHash === materialHash) {
return existing
}
if (existing) {
disposeOwnedMaterials([
existing.invisible,
existing.deleteVisible,
existing.deleteInvisible,
existing.highlightedVisible,
existing.highlightedInvisible,
])
}
const interiorSpec = getEffectiveWallSurfaceMaterial(wallNode, 'interior')
const exteriorSpec = getEffectiveWallSurfaceMaterial(wallNode, 'exterior')
const visible: WallMaterialArray = [
baseMaterial,
getSurfaceVisibleMaterial(interiorSpec),
getSurfaceVisibleMaterial(exteriorSpec),
]
const invisible: WallMaterialArray = [
createInvisibleWallMaterial(DEFAULT_WALL_COLOR),
createInvisibleWallMaterial(getSurfaceColor(interiorSpec, DEFAULT_WALL_COLOR)),
createInvisibleWallMaterial(getSurfaceColor(exteriorSpec, DEFAULT_WALL_COLOR)),
]
const highlightedVisible = mapWallMaterialArray(visible, (material) =>
createHighlightedWallMaterial(material, 'selection'),
)
const highlightedInvisible = mapWallMaterialArray(invisible, (material) =>
createHighlightedWallMaterial(material, 'selection'),
)
const deleteVisible = mapWallMaterialArray(visible, (material) =>
createHighlightedWallMaterial(material, 'delete'),
)
const deleteInvisible = mapWallMaterialArray(invisible, (material) =>
createHighlightedWallMaterial(material, 'delete'),
)
const result: WallMaterials = {
visible,
invisible,
deleteVisible,
deleteInvisible,
highlightedVisible,
highlightedInvisible,
materialHash,
}
wallMaterialCache.set(cacheKey, result)
return result
}
export function getVisibleWallMaterials(wallNode: WallNode): WallMaterialArray {
return getMaterialsForWall(wallNode).visible
}