diff --git a/.claude/skills/review-architecture/SKILL.md b/.claude/skills/review-architecture/SKILL.md new file mode 100644 index 00000000..84cb6a80 --- /dev/null +++ b/.claude/skills/review-architecture/SKILL.md @@ -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 + +# 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 --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: ``, 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 `` 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, `` 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 ``, 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 — 1–5 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 diff --git a/.cursor/rules/tools.mdc b/.cursor/rules/tools.mdc index 840e1d57..016cd002 100644 --- a/.cursor/rules/tools.mdc +++ b/.cursor/rules/tools.mdc @@ -60,11 +60,15 @@ export function MyTool() { ## 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. - **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. -- **Tools must not import from `@pascal-app/viewer`** — use the scene store and core hooks only. +- **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. `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`. ## Adding a New Tool diff --git a/.cursor/skills/review-architecture/SKILL.md b/.cursor/skills/review-architecture/SKILL.md new file mode 120000 index 00000000..0fb1d1aa --- /dev/null +++ b/.cursor/skills/review-architecture/SKILL.md @@ -0,0 +1 @@ +../../../.claude/skills/review-architecture/SKILL.md \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..42b0a6fe --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/apps/editor/public/icons/paint.png b/apps/editor/public/icons/paint.png new file mode 100644 index 00000000..d66167bb Binary files /dev/null and b/apps/editor/public/icons/paint.png differ diff --git a/bun.lock b/bun.lock index 56ac447c..95fd2c69 100644 --- a/bun.lock +++ b/bun.lock @@ -50,7 +50,7 @@ }, "packages/core": { "name": "@pascal-app/core", - "version": "0.5.1", + "version": "0.6.0", "dependencies": { "dedent": "^1.7.1", "idb-keyval": "^6.2.2", @@ -77,7 +77,7 @@ }, "packages/editor": { "name": "@pascal-app/editor", - "version": "0.5.1", + "version": "0.6.0", "dependencies": { "@iconify/react": "^6.0.2", "@number-flow/react": "^0.5.14", @@ -108,8 +108,8 @@ "zustand": "^5.0.11", }, "devDependencies": { - "@pascal-app/core": "^0.5.1", - "@pascal-app/viewer": "^0.5.1", + "@pascal-app/core": "^0.6.0", + "@pascal-app/viewer": "^0.6.0", "@pascal/typescript-config": "*", "@types/howler": "^2.2.12", "@types/node": "^22.19.12", @@ -119,8 +119,8 @@ "typescript": "5.9.3", }, "peerDependencies": { - "@pascal-app/core": "^0.5.1", - "@pascal-app/viewer": "^0.5.1", + "@pascal-app/core": "^0.6.0", + "@pascal-app/viewer": "^0.6.0", "@react-three/drei": "^10", "@react-three/fiber": "^9", "next": ">=15", @@ -189,7 +189,7 @@ }, "packages/viewer": { "name": "@pascal-app/viewer", - "version": "0.5.1", + "version": "0.6.0", "dependencies": { "polygon-clipping": "^0.15.7", "zustand": "^5", @@ -202,7 +202,7 @@ "typescript": "5.9.3", }, "peerDependencies": { - "@pascal-app/core": "^0.5.1", + "@pascal-app/core": "^0.6.0", "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", diff --git a/packages/core/package.json b/packages/core/package.json index 7519b073..13128823 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@pascal-app/core", - "version": "0.5.1", + "version": "0.6.0", "description": "Core library for Pascal 3D building editor", "type": "module", "main": "./dist/index.js", diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 7d2d9be2..b313008b 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -1,4 +1,5 @@ import type { ThreeEvent } from '@react-three/fiber' +import type { Object3D } from 'three' import mitt from 'mitt' import type { BuildingNode, @@ -38,6 +39,8 @@ export interface NodeEvent { position: [number, number, number] localPosition: [number, number, number] normal?: [number, number, number] + faceIndex?: number + object: Object3D stopPropagation: () => void nativeEvent: ThreeEvent } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ba55d0e3..42ae2514 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -42,9 +42,11 @@ export { getCatalogMaterialById, getLibraryMaterialIdFromRef, getMaterialPresetByRef, - getMaterialsForTarget, + getMaterialsForCategory, LIBRARY_MATERIAL_REF_PREFIX, MATERIAL_CATALOG, + MATERIAL_CATEGORIES, + type MaterialCategory, type MaterialCatalogItem, toLibraryMaterialRef, } from './material-library' @@ -55,6 +57,12 @@ export { type ItemInteractiveState, useInteractive, } 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 { clearSceneHistory, default as useScene } from './store/use-scene' export { CeilingSystem } from './systems/ceiling/ceiling-system' diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts index 9aaed406..eb7ef6cc 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -4,6 +4,11 @@ import { isCurvedWall, } from '../systems/wall/wall-curve' 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' type Point2D = { x: number; y: number } @@ -855,6 +860,7 @@ export function initSpaceDetectionSync(sceneStore: any, editorStore: any): () => const unsubscribe = sceneStore.subscribe((state: any) => { if (isProcessing) return + if (getSceneHistoryPauseDepth() > 0) return const nodes = state.nodes const wallsByLevel = new Map() @@ -889,11 +895,11 @@ export function initSpaceDetectionSync(sceneStore: any, editorStore: any): () => } isProcessing = true - sceneStore.temporal.getState().pause() + pauseSceneHistory(sceneStore) try { runSpaceDetection([...levelsToUpdate], sceneStore, editorStore, nodes) } finally { - sceneStore.temporal.getState().resume() + resumeSceneHistory(sceneStore) previousSnapshots.clear() for (const [levelId, snapshot] of currentSnapshots.entries()) { previousSnapshots.set(levelId, snapshot) diff --git a/packages/core/src/material-library.ts b/packages/core/src/material-library.ts index 06218708..5b08bec1 100644 --- a/packages/core/src/material-library.ts +++ b/packages/core/src/material-library.ts @@ -1,57 +1,33 @@ import { type MaterialPresetPayload, - type MaterialTarget, - MaterialTarget as MaterialTargetSchema, } from './schema/material' export type MaterialCatalogItem = { id: string label: string + category: MaterialCategory description?: string - targets: MaterialTarget[] previewThumbnailUrl?: string previewColor?: string preset: MaterialPresetPayload } -const WALL_TARGETS: MaterialTarget[] = [ - MaterialTargetSchema.enum.wall, -] - -const SLAB_TARGETS: MaterialTarget[] = [ - MaterialTargetSchema.enum.slab, -] - -const WALL_AND_SLAB_TARGETS: MaterialTarget[] = [ - 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_CATEGORIES = [ + 'wood', + 'wallpaper', + 'parquet', + 'granite', + 'marble', + 'other', +] as const +export type MaterialCategory = (typeof MATERIAL_CATEGORIES)[number] export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'wall-wood1', label: 'Wood', + category: 'wood', description: 'Warm wood finish', - targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS], previewThumbnailUrl: '/material/wood1/wood1_thumbnail.webp', preset: { maps: { @@ -85,8 +61,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'wall-wood2', label: 'Wood', + category: 'wood', description: 'Textured wood finish', - targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS], previewThumbnailUrl: '/material/wood2/wood2_thumbnail.webp', preset: { maps: { @@ -121,8 +97,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'wall-wood3', label: 'Wood', + category: 'wood', description: 'Knotted timber finish', - targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS], previewThumbnailUrl: '/material/wood3/wood3_thumbnail.webp', preset: { maps: { @@ -155,8 +131,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'wall-wood4', label: 'Wood', + category: 'wood', description: 'Oak stretcher finish', - targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS], previewThumbnailUrl: '/material/wood4/wood4_thumbnail.webp', preset: { maps: { @@ -189,8 +165,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'wall-wood5', label: 'Wood', + category: 'wood', description: 'Rich grain wood finish', - targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS], previewThumbnailUrl: '/material/wood5/wood5_thumnail.webp', preset: { maps: { @@ -225,8 +201,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'wall-granite1', label: 'Granite', + category: 'granite', description: 'Polished granite finish', - targets: SLAB_TARGETS, previewThumbnailUrl: '/material/granite1/granite_thumbnail.webp', preset: { maps: { @@ -259,8 +235,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'wall-marble1', label: 'Marble', + category: 'marble', description: 'Smooth marble finish', - targets: [...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS], previewThumbnailUrl: '/material/marble1/marble1_thumbnail.webp', preset: { maps: { @@ -293,8 +269,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'wall-marble2', label: 'Marble', + category: 'marble', description: 'Soft marble finish', - targets: [...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS], previewThumbnailUrl: '/material/marble2/marble2_thumbnail.webp', preset: { maps: { @@ -327,8 +303,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'wall-parquet1', label: 'Parquet', + category: 'parquet', description: 'Parquet wood finish', - targets: SLAB_TARGETS, previewThumbnailUrl: '/material/parquet1/parquet_thumnail.webp', preset: { maps: { @@ -361,8 +337,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'wall-parquet2', label: 'Parquet', + category: 'parquet', description: 'Soft parquet finish', - targets: SLAB_TARGETS, previewThumbnailUrl: '/material/parquet2/parquet2_thumbnail.webp', preset: { maps: { @@ -395,8 +371,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'wall-wallpaper1', label: 'Wallpaper', + category: 'wallpaper', description: 'Soft wallpaper finish', - targets: WALL_TARGETS, previewThumbnailUrl: '/material/wallpaper1/wallpaper1_thumbnail.webp', preset: { maps: { @@ -430,8 +406,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'wall-wallpaper2', label: 'Wallpaper', + category: 'wallpaper', description: 'Decorative wallpaper finish', - targets: WALL_TARGETS, previewThumbnailUrl: '/material/wallpaper2/wallpaper2_thumnail.webp', preset: { maps: { @@ -464,8 +440,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'wall-wallpaper3', label: 'Wallpaper', + category: 'wallpaper', description: 'Patterned wallpaper finish', - targets: WALL_TARGETS, previewThumbnailUrl: '/material/wallpaper3/wallpaper3_thumbnail.webp', preset: { maps: { @@ -498,14 +474,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'preset-white', label: 'White', + category: 'other', description: 'Clean painted finish', - targets: [ - ...WALL_TARGETS, - ...SLAB_TARGETS, - ...ROOF_TARGETS, - ...STAIR_AND_FENCE_TARGETS, - ...CEILING_TARGETS, - ], previewColor: '#ffffff', preset: { maps: {}, @@ -536,8 +506,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'preset-metal', label: 'Metal', + category: 'other', description: 'Brushed metal finish', - targets: [...WALL_TARGETS, ...SLAB_TARGETS], previewColor: '#c0c0c0', preset: { maps: {}, @@ -568,8 +538,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'preset-glass', label: 'Glass', + category: 'other', description: 'Light glass finish', - targets: [...WALL_TARGETS, ...SLAB_TARGETS], previewColor: '#87ceeb', preset: { maps: {}, @@ -599,8 +569,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, ] -export function getMaterialsForTarget(target: MaterialTarget): MaterialCatalogItem[] { - return MATERIAL_CATALOG.filter((item) => item.targets.includes(target)) +export function getMaterialsForCategory(category: MaterialCategory): MaterialCatalogItem[] { + return MATERIAL_CATALOG.filter((item) => item.category === category) } export function getCatalogMaterialById(id?: string): MaterialCatalogItem | undefined { diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 076ebc72..1383216d 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -43,22 +43,30 @@ export type { } from './nodes/item' export { getScaledDimensions, ItemNode } from './nodes/item' 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 { ScanNode } from './nodes/scan' // Nodes export { SiteNode } from './nodes/site' export { SlabNode } from './nodes/slab' export { + getEffectiveStairSurfaceMaterial, StairNode, StairRailingMode, StairSlabOpeningMode, StairTopLandingMode, StairType, } from './nodes/stair' +export type { StairSurfaceMaterialRole, StairSurfaceMaterialSpec } from './nodes/stair' export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment' 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 { ZoneNode } from './nodes/zone' export type { AnyNodeId, AnyNodeType } from './types' diff --git a/packages/core/src/schema/nodes/fence.ts b/packages/core/src/schema/nodes/fence.ts index d1592081..234da407 100644 --- a/packages/core/src/schema/nodes/fence.ts +++ b/packages/core/src/schema/nodes/fence.ts @@ -13,6 +13,7 @@ export const FenceNode = BaseNode.extend({ materialPreset: z.string().optional(), start: z.tuple([z.number(), z.number()]), end: z.tuple([z.number(), z.number()]), + curveOffset: z.number().optional(), height: z.number().default(1.8), thickness: z.number().default(0.08), baseHeight: z.number().default(0.22), @@ -28,6 +29,7 @@ export const FenceNode = BaseNode.extend({ dedent` Fence node - used to represent a fence segment in the building/site 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 - baseHeight/postSpacing/postSize/topRailHeight: exact geometric controls from the plan3D fence model - groundClearance/edgeInset/baseStyle: fence support and inset configuration diff --git a/packages/core/src/schema/nodes/roof.ts b/packages/core/src/schema/nodes/roof.ts index d6529b4e..944cca04 100644 --- a/packages/core/src/schema/nodes/roof.ts +++ b/packages/core/src/schema/nodes/roof.ts @@ -1,14 +1,26 @@ import dedent from 'dedent' import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' -import { MaterialSchema } from '../material' +import { type MaterialSchema, MaterialSchema as MaterialSchemaSchema } from '../material' import { RoofSegmentNode } from './roof-segment' +export type RoofSurfaceMaterialRole = 'top' | 'edge' | 'wall' +export type RoofSurfaceMaterialSpec = { + material?: MaterialSchema + materialPreset?: string +} + export const RoofNode = BaseNode.extend({ id: objectId('roof'), type: nodeType('roof'), - material: MaterialSchema.optional(), + material: MaterialSchemaSchema.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]), // Rotation around Y axis in radians rotation: z.number().default(0), @@ -26,3 +38,66 @@ export const RoofNode = BaseNode.extend({ ) export type RoofNode = z.infer + +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) +} diff --git a/packages/core/src/schema/nodes/stair.ts b/packages/core/src/schema/nodes/stair.ts index f6390b34..81969828 100644 --- a/packages/core/src/schema/nodes/stair.ts +++ b/packages/core/src/schema/nodes/stair.ts @@ -1,7 +1,7 @@ import dedent from 'dedent' import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' -import { MaterialSchema } from '../material' +import { type MaterialSchema, MaterialSchema as MaterialSchemaSchema } from '../material' import { StairSegmentNode } from './stair-segment' export const StairRailingMode = z.enum(['none', 'left', 'right', 'both']) @@ -13,12 +13,23 @@ export type StairRailingMode = z.infer export type StairType = z.infer export type StairTopLandingMode = z.infer export type StairSlabOpeningMode = z.infer +export type StairSurfaceMaterialRole = 'railing' | 'tread' | 'side' +export type StairSurfaceMaterialSpec = { + material?: MaterialSchema + materialPreset?: string +} export const StairNode = BaseNode.extend({ id: objectId('stair'), type: nodeType('stair'), - material: MaterialSchema.optional(), + material: MaterialSchemaSchema.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]), // Rotation around Y axis in radians rotation: z.number().default(0), @@ -71,3 +82,74 @@ export const StairNode = BaseNode.extend({ ) export type StairNode = z.infer + +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) +} diff --git a/packages/core/src/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts index ccbec814..ebce121c 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -12,8 +12,14 @@ export const WallNode = BaseNode.extend({ children: z .array(z.union([ItemNode.shape.id, DoorNode.shape.id, WindowNode.shape.id])) .default([]), + // Legacy single-material wall finish. Read for backward compatibility only. material: MaterialSchema.optional(), + // Legacy single-material wall finish preset. Read for backward compatibility only. materialPreset: z.string().optional(), + interiorMaterial: MaterialSchema.optional(), + interiorMaterialPreset: z.string().optional(), + exteriorMaterial: MaterialSchema.optional(), + exteriorMaterialPreset: z.string().optional(), thickness: z.number().optional(), height: z.number().optional(), curveOffset: z.number().optional(), @@ -37,3 +43,62 @@ export const WallNode = BaseNode.extend({ `, ) export type WallNode = z.infer + +export type WallSurfaceSide = 'interior' | 'exterior' + +export type WallSurfaceMaterialSpec = { + material?: z.infer + materialPreset?: string +} + +type WallSurfaceMaterialSource = { + material?: z.infer + materialPreset?: string + interiorMaterial?: z.infer + interiorMaterialPreset?: string + exteriorMaterial?: z.infer + 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, + }) +} diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index f6e64a7d..a855871d 100644 --- a/packages/core/src/store/actions/node-actions.ts +++ b/packages/core/src/store/actions/node-actions.ts @@ -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 { SceneState } from '../use-scene' @@ -17,11 +23,7 @@ type WallMergePlan = { let pendingRafId: number | null = null let pendingUpdates: Set = new Set() -function pointsEqual( - a: [number, number], - b: [number, number], - tolerance = 1e-6, -) { +function pointsEqual(a: [number, number], b: [number, number], tolerance = 1e-6) { const dx = a[0] - b[0] const dz = a[1] - b[1] return dx * dx + dz * dz <= tolerance * tolerance @@ -40,32 +42,30 @@ function getWallEndpointAtPoint( return null } -function getWallFreeEndpoint( - wall: Pick, - sharedPoint: [number, number], -) { +function getWallFreeEndpoint(wall: Pick, sharedPoint: [number, number]) { return pointsEqual(wall.start, sharedPoint) ? wall.end : wall.start } 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 ( (a.parentId ?? null) === (b.parentId ?? null) && 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.height ?? 2.5) - (b.height ?? 2.5)) <= 1e-6 && - a.materialPreset === b.materialPreset && - JSON.stringify(a.material ?? null) === JSON.stringify(b.material ?? null) && + aInterior === bInterior && + aExterior === bExterior && a.frontSide === b.frontSide && a.backSide === b.backSide && a.visible === b.visible ) } -function areWallsCollinearAcrossPoint( - a: WallNode, - b: WallNode, - sharedPoint: [number, number], -) { +function areWallsCollinearAcrossPoint(a: WallNode, b: WallNode, sharedPoint: [number, number]) { const freeA = getWallFreeEndpoint(a, sharedPoint) const freeB = getWallFreeEndpoint(b, sharedPoint) const ax = freeA[0] - sharedPoint[0] @@ -111,7 +111,10 @@ function buildMergedWallAttachmentUpdates( mergedEnd: [number, number], nodes: Record, ): 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 tangentZ = (mergedEnd[1] - mergedStart[1]) / mergedLength const updates: WallAttachmentUpdate[] = [] @@ -126,11 +129,16 @@ function buildMergedWallAttachmentUpdates( const sourceWall = child.parentId === secondary.id ? secondary : primary const sourceLength = Math.max(wallLength(sourceWall), 1e-6) 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 worldZ = sourceWall.start[1] + ((sourceWall.end[1] - sourceWall.start[1]) * localX) / sourceLength + const worldX = + 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( 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({ diff --git a/packages/core/src/store/history-control.ts b/packages/core/src/store/history-control.ts new file mode 100644 index 00000000..51e191b6 --- /dev/null +++ b/packages/core/src/store/history-control.ts @@ -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 +} diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 543ac30a..d4aa2c45 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -11,6 +11,7 @@ import { SiteNode } from '../schema/nodes/site' import { StairNode as StairNodeSchema } from '../schema/nodes/stair' import { StairSegmentNode as StairSegmentNodeSchema } from '../schema/nodes/stair-segment' import type { AnyNode, AnyNodeId } from '../schema/types' +import { resetSceneHistoryPauseDepth } from './history-control' import * as nodeActions from './actions/node-actions' function getFiniteNumber(value: unknown, fallback: number) { @@ -100,6 +101,189 @@ function normalizeStairSegmentNode(node: Record) { return parsed.success ? parsed.data : null } +function migrateWallSurfaceMaterials(node: Record) { + 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) { + 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) { + 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): Record { const patchedNodes = { ...nodes } for (const [id, node] of Object.entries(patchedNodes)) { @@ -141,7 +325,7 @@ function migrateNodes(nodes: Record): Record { } if (node.type === 'stair') { - const normalized = normalizeStairNode(node) + const normalized = normalizeStairNode(migrateStairSurfaceMaterials(node)) if (normalized) { patchedNodes[id] = normalized } @@ -153,6 +337,14 @@ function migrateNodes(nodes: Record): Record { 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 } @@ -439,6 +631,7 @@ let prevNodesSnapshot: Record | null = null export function clearSceneHistory() { useScene.temporal.getState().clear() + resetSceneHistoryPauseDepth() prevPastLength = 0 prevFutureLength = 0 prevNodesSnapshot = null @@ -458,8 +651,9 @@ useScene.temporal.subscribe((state) => { // Capture the previous snapshot before RAF fires const snapshotBefore = prevNodesSnapshot - // Use RAF to ensure all middleware and store updates are complete - requestAnimationFrame(() => { + // Defer to a microtask so the scene store has settled before we diff, + // but still mark walls/items dirty before the next paint. + queueMicrotask(() => { const currentNodes = useScene.getState().nodes const { markDirty } = useScene.getState() diff --git a/packages/core/src/systems/fence/fence-system.tsx b/packages/core/src/systems/fence/fence-system.tsx index 70d29b74..4fcc1a86 100644 --- a/packages/core/src/systems/fence/fence-system.tsx +++ b/packages/core/src/systems/fence/fence-system.tsx @@ -4,12 +4,90 @@ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' import type { AnyNodeId, FenceNode } from '../../schema' import useScene from '../../store/use-scene' +import { getWallCurveFrameAt, getWallCurveLength } from '../wall/wall-curve' type FencePart = { position: [number, number, number] + rotationY?: 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) { const position = geometry.getAttribute('position') const normal = geometry.getAttribute('normal') @@ -20,26 +98,13 @@ function applyFenceUVs(geometry: THREE.BufferGeometry) { let minX = Number.POSITIVE_INFINITY let minY = 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) { - const px = position.getX(index) - const py = position.getY(index) - const pz = 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) + minX = Math.min(minX, position.getX(index)) + minY = Math.min(minY, position.getY(index)) + minZ = Math.min(minZ, position.getZ(index)) } - 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) { const px = position.getX(index) const py = position.getY(index) @@ -52,14 +117,14 @@ function applyFenceUVs(geometry: THREE.BufferGeometry) { let v = 0 if (ny >= nx && ny >= nz) { - u = (px - minX) / width - v = (pz - minZ) / depth + u = px - minX + v = pz - minZ } else if (nx >= nz) { - u = (pz - minZ) / depth - v = (py - minY) / height + u = pz - minZ + v = py - minY } else { - u = (px - minX) / width - v = (py - minY) / height + u = px - minX + v = py - minY } uvs[index * 2] = u @@ -84,10 +149,7 @@ function getStyleDefaults(style: FenceNode['style']) { function createFenceParts(fence: FenceNode): FencePart[] { const parts: FencePart[] = [] - const length = Math.max( - Math.hypot(fence.end[0] - fence.start[0], fence.end[1] - fence.start[1]), - 0.01, - ) + const length = Math.max(getWallCurveLength(fence), 0.01) const panelDepth = Math.max(fence.thickness, 0.03) const clearance = Math.max(fence.groundClearance, 0) const styleDefaults = getStyleDefaults(fence.style) @@ -100,31 +162,39 @@ function createFenceParts(fence: FenceNode): FencePart[] { const isFloating = fence.baseStyle === 'floating' const baseY = isFloating ? clearance : 0 const effectiveBaseHeight = baseHeight + const startInsetT = Math.min(0.499, edgeInset / length) + const endInsetT = Math.max(0.501, 1 - edgeInset / length) if (!isFloating) { - parts.push({ - position: [0, baseY + effectiveBaseHeight / 2, 0], - scale: [length, effectiveBaseHeight, panelDepth * 1.05], - }) - parts.push({ - position: [0, baseY + effectiveBaseHeight + verticalHeight * 0.15, 0], - scale: [length, topRailHeight * 0.8, panelDepth * 0.35], - }) + parts.push( + ...createFenceCurveSpanParts( + fence, + 0, + 1, + baseY + effectiveBaseHeight / 2, + 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 step = count > 1 ? (length - edgeInset * 2) / (count - 1) : 0 - const startX = -length / 2 + edgeInset const verticalY = baseY + effectiveBaseHeight + verticalHeight / 2 for (let index = 0; index < count; index += 1) { - const x = count === 1 ? 0 : startX + step * index - let posX = x + const t = count === 1 ? 0.5 : startInsetT + (endInsetT - startInsetT) * (index / (count - 1)) + const frame = getFencePointAt(fence, t) 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 = isFloating && isEdgePost ? effectiveBaseHeight + verticalHeight + topRailHeight + clearance @@ -132,21 +202,34 @@ function createFenceParts(fence: FenceNode): FencePart[] { const postY = isFloating && isEdgePost ? postHeight / 2 : verticalY 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)], }) } - parts.push({ - position: [0, baseY + effectiveBaseHeight + verticalHeight + topRailHeight / 2, 0], - scale: [length, topRailHeight, Math.max(panelDepth * 0.55, 0.018)], - }) + parts.push( + ...createFenceCurveSpanParts( + fence, + 0, + 1, + baseY + effectiveBaseHeight + verticalHeight + topRailHeight / 2, + topRailHeight, + Math.max(panelDepth * 0.55, 0.018), + ), + ) if (isFloating) { - parts.push({ - position: [0, baseY + effectiveBaseHeight + topRailHeight / 2, 0], - scale: [length, topRailHeight, Math.max(panelDepth * 0.55, 0.018)], - }) + parts.push( + ...createFenceCurveSpanParts( + fence, + 0, + 1, + baseY + effectiveBaseHeight + topRailHeight / 2, + topRailHeight, + Math.max(panelDepth * 0.55, 0.018), + ), + ) } return parts @@ -154,16 +237,14 @@ function createFenceParts(fence: FenceNode): FencePart[] { function generateFenceGeometry(fence: FenceNode) { const parts = createFenceParts(fence) - const geometries = parts.map((part) => { - 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 geometries = parts.map(createFencePartGeometry) const merged = mergeGeometries(geometries, false) ?? new THREE.BufferGeometry() 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() return merged } @@ -178,12 +259,8 @@ function updateFenceGeometry(fenceId: FenceNode['id']) { const newGeometry = generateFenceGeometry(node) mesh.geometry.dispose() mesh.geometry = newGeometry - - const centerX = (node.start[0] + node.end[0]) / 2 - 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) + mesh.position.set(0, 0, 0) + mesh.rotation.set(0, 0, 0) } export const FenceSystem = () => { diff --git a/packages/core/src/systems/roof/roof-system.tsx b/packages/core/src/systems/roof/roof-system.tsx index 0e23364b..5a1f06d1 100644 --- a/packages/core/src/systems/roof/roof-system.tsx +++ b/packages/core/src/systems/roof/roof-system.tsx @@ -11,7 +11,7 @@ import useScene from '../../store/use-scene' const csgEvaluator = new Evaluator() csgEvaluator.useGroups = true ;(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) { brush.geometry.computeBoundsTree = computeBoundsTree @@ -25,6 +25,7 @@ const _position = new THREE.Vector3() const _quaternion = new THREE.Quaternion() const _scale = new THREE.Vector3(1, 1, 1) const _yAxis = new THREE.Vector3(0, 1, 0) +const _uvFaceNormal = new THREE.Vector3() // Pending merged-roof updates carried across frames (for throttling) const pendingRoofUpdates = new Set() @@ -251,6 +252,7 @@ function updateMergedRoofGeometry( g.materialIndex = mapRoofGroupMaterialIndex(g.materialIndex, resultMaterials, matToIndex) } + ensureUv2Attribute(resultGeo) resultGeo.computeVertexNormals() mergedMesh.geometry.dispose() mergedMesh.geometry = resultGeo @@ -641,6 +643,7 @@ export function generateRoofSegmentGeometry(node: RoofSegmentNode): THREE.Buffer wallBrush.geometry.dispose() innerBrush.geometry.dispose() + ensureUv2Attribute(resultGeo) resultGeo.computeVertexNormals() return resultGeo } @@ -936,6 +939,7 @@ function createGeometryFromFaces( ): THREE.BufferGeometry { const positions: number[] = [] const normals: number[] = [] + const uvs: number[] = [] const indices: number[] = [] const groups: { start: number; count: number; materialIndex: number }[] = [] 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) + pushRoofUv(uvs, p0, normal) + pushRoofUv(uvs, fi, normal) + pushRoofUv(uvs, fi1, normal) + indices.push(vertexCount, vertexCount + 1, vertexCount + 2) faceVertexCount += 3 @@ -990,6 +998,7 @@ function createGeometryFromFaces( const geometry = new THREE.BufferGeometry() geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3)) + geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)) geometry.setIndex(indices) for (const g of groups) { @@ -999,6 +1008,34 @@ function createGeometryFromFaces( // Merge identical vertices to optimize geometry for CSG and create clean topology const mergedGeo = mergeVertices(geometry, 1e-4) geometry.dispose() + ensureUv2Attribute(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)) +} diff --git a/packages/core/src/systems/stair/stair-system.tsx b/packages/core/src/systems/stair/stair-system.tsx index db0bc3d2..3d0964e6 100644 --- a/packages/core/src/systems/stair/stair-system.tsx +++ b/packages/core/src/systems/stair/stair-system.tsx @@ -12,6 +12,10 @@ import { syncAutoStairOpenings } from './stair-opening-sync' const pendingStairUpdates = new Set() const MAX_STAIRS_PER_FRAME = 2 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 @@ -198,7 +202,7 @@ function generateStairSegmentGeometry( shape.lineTo(0, 0) - const geometry = new THREE.ExtrudeGeometry(shape, { + const extrudedGeometry = new THREE.ExtrudeGeometry(shape, { steps: 1, depth: width, bevelEnabled: false, @@ -209,7 +213,16 @@ function generateStairSegmentGeometry( const matrix = new THREE.Matrix4() matrix.makeRotationY(-Math.PI / 2) 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 } @@ -219,6 +232,7 @@ function updateStairSegmentGeometry(node: StairSegmentNode, mesh: THREE.Mesh) { const absoluteHeight = computeAbsoluteHeight(node) const newGeometry = generateStairSegmentGeometry(node, absoluteHeight) + applyStraightStairMaterialGroups(newGeometry) mesh.geometry.dispose() mesh.geometry = newGeometry @@ -363,6 +377,7 @@ function updateMergedStairGeometry( } const merged = mergeGeometries(geometries, false) ?? createEmptyGeometry() + applyStraightStairMaterialGroups(merged) replaceMeshGeometry(mergedMesh, merged) // 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 // ============================================================================ @@ -441,6 +558,8 @@ function rotateXZ(x: number, z: number, angle: number): [number, number] { function createEmptyGeometry(): THREE.BufferGeometry { const geometry = new THREE.BufferGeometry() 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 } diff --git a/packages/core/src/systems/wall/wall-curve.ts b/packages/core/src/systems/wall/wall-curve.ts index 5b6c281c..902050ba 100644 --- a/packages/core/src/systems/wall/wall-curve.ts +++ b/packages/core/src/systems/wall/wall-curve.ts @@ -1,10 +1,10 @@ import type { Point2D } from './wall-mitering' -import type { WallNode } from '../../schema' +import type { FenceNode, WallNode } from '../../schema' const CURVE_EPSILON = 1e-6 const DEFAULT_SAMPLE_SEGMENTS = 24 -type WallCurveLike = Pick +type WallCurveLike = Pick type CurveFrame = { point: Point2D @@ -198,7 +198,7 @@ export function getWallCurveLength(wall: WallCurveLike, segments = DEFAULT_SAMPL } export function getWallSurfacePolygon( - wall: Pick, + wall: Pick, segments = DEFAULT_SAMPLE_SEGMENTS, miterOverrides?: WallSurfaceMiterOverrides, ) { diff --git a/packages/core/src/systems/wall/wall-system.tsx b/packages/core/src/systems/wall/wall-system.tsx index 062eb1f6..5a40c09a 100644 --- a/packages/core/src/systems/wall/wall-system.tsx +++ b/packages/core/src/systems/wall/wall-system.tsx @@ -7,20 +7,30 @@ import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manage import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync' import type { AnyNode, AnyNodeId, WallNode } from '../../schema' import useScene from '../../store/use-scene' -import { DEFAULT_WALL_HEIGHT, getWallPlanFootprint, getWallThickness } from './wall-footprint' import { getWallCurveFrameAt, getWallSurfacePolygon, isCurvedWall } from './wall-curve' +import { DEFAULT_WALL_HEIGHT, getWallPlanFootprint, getWallThickness } from './wall-footprint' import { calculateLevelMiters, getAdjacentWallIds, getWallMiterBoundaryPoints, type Point2D, - type WallMiterData, pointToKey, + type WallMiterData, } from './wall-mitering' // Reusable CSG evaluator for better performance const csgEvaluator = new Evaluator() 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) { const uv = geometry.getAttribute('uv') @@ -78,6 +88,207 @@ function insetCurvedWallBoundaryPointsFor3D( 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, + 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(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 // ============================================================================ @@ -252,6 +463,7 @@ export function generateExtrudedWall( // Convert polygon to local coordinates const localPoints = polyPoints.map(worldToLocal) + const boundaryEdges = buildTaggedWallBoundaryEdges(wallNode, localPoints, miterData) // Build THREE.js shape // 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) geometry.rotateX(-Math.PI / 2) geometry.computeVertexNormals() + assignWallMaterialGroups(geometry, wallNode, boundaryEdges) ensureUv2Attribute(geometry) // Apply CSG subtraction for cutouts (doors/windows) @@ -307,6 +520,7 @@ export function generateExtrudedWall( const resultGeometry = resultBrush.geometry resultGeometry.computeVertexNormals() + assignWallMaterialGroups(resultGeometry, wallNode, boundaryEdges) ensureUv2Attribute(resultGeometry) return resultGeometry diff --git a/packages/editor/package.json b/packages/editor/package.json index ef8403b1..0b0eb3c3 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -1,6 +1,6 @@ { "name": "@pascal-app/editor", - "version": "0.5.1", + "version": "0.6.0", "description": "Pascal building editor component", "type": "module", "exports": { @@ -11,8 +11,8 @@ "check-types": "tsc --noEmit" }, "peerDependencies": { - "@pascal-app/core": "^0.5.1", - "@pascal-app/viewer": "^0.5.1", + "@pascal-app/core": "^0.6.0", + "@pascal-app/viewer": "^0.6.0", "@react-three/drei": "^10", "@react-three/fiber": "^9", "next": ">=15", @@ -50,8 +50,8 @@ "zustand": "^5.0.11" }, "devDependencies": { - "@pascal-app/core": "^0.5.1", - "@pascal-app/viewer": "^0.5.1", + "@pascal-app/core": "^0.6.0", + "@pascal-app/viewer": "^0.6.0", "@pascal/typescript-config": "*", "@types/howler": "^2.2.12", "@types/node": "^22.19.12", diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index 3fc7853f..a3ab3da8 100755 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -49,9 +49,13 @@ export function FloatingActionMenu() { const mode = useEditor((s) => s.mode) const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered) 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 setMovingWallEndpoint = useEditor((s) => s.setMovingWallEndpoint) + const setMovingFenceEndpoint = useEditor((s) => s.setMovingFenceEndpoint) const setCurvingWall = useEditor((s) => s.setCurvingWall) + const setCurvingFence = useEditor((s) => s.setCurvingFence) const setSelection = useViewer((s) => s.setSelection) 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) } - if (node?.type === 'wall') { - const wall = node as WallNode - const wallLength = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) + if (node?.type === 'wall' || node?.type === 'fence') { + const segment = node as WallNode | FenceNode const endpointYOffset = 0.35 - const startWorld = obj.localToWorld(new THREE.Vector3(0, 0, 0)) - const endWorld = obj.localToWorld(new THREE.Vector3(wallLength, 0, 0)) + const startWorld = + 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) { startEndpointGroupRef.current.position.set( @@ -180,22 +198,35 @@ export function FloatingActionMenu() { const handleCurve = useCallback( (e: React.MouseEvent) => { e.stopPropagation() - if (!canCurveSelectedWall || !node || node.type !== 'wall') return + if (!node) return 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: [] }) }, - [canCurveSelectedWall, node, setCurvingWall, setSelection], + [canCurveSelectedWall, node, setCurvingFence, setCurvingWall, setSelection], ) const handleEndpointMove = useCallback( (endpoint: 'start' | 'end', e: React.MouseEvent) => { e.stopPropagation() - if (!(node && node.type === 'wall')) return + if (!node) return 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: [] }) }, - [node, setMovingWallEndpoint, setSelection], + [node, setMovingFenceEndpoint, setMovingWallEndpoint, setSelection], ) const handleDuplicate = useCallback( @@ -396,7 +427,9 @@ export function FloatingActionMenu() { if ( !(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') || - movingWallEndpoint + movingWallEndpoint || + movingFenceEndpoint || + curvingFence ) return null @@ -413,7 +446,11 @@ export function FloatingActionMenu() { > - {node?.type === 'wall' && ( + {(node?.type === 'wall' || node?.type === 'fence') && ( <> - ))} - {onChange ? ( - - ) : null} +
+
+
+ {MATERIAL_CATEGORIES.map((category) => ( + + ))} +
-
- )} - - {showCustom && onChange && ( -
-
- - handlePropertyChange('color', e.target.value)} - type="color" - value={currentProps.color} - /> - handlePropertyChange('color', e.target.value)} - type="text" - value={currentProps.color} - /> -
- -
- - handlePropertyChange('roughness', Number.parseFloat(e.target.value))} - step={0.01} - type="range" - value={currentProps.roughness} - /> - - {currentProps.roughness.toFixed(2)} - -
- -
- - handlePropertyChange('metalness', Number.parseFloat(e.target.value))} - step={0.01} - type="range" - value={currentProps.metalness} - /> - - {currentProps.metalness.toFixed(2)} - -
- -
- - { - 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} - /> - - {currentProps.opacity.toFixed(2)} - -
- -
- - +
+
+ {catalogItems.map((item) => ( + + ))} + {selectedCategory === 'other' && onChange ? ( + + ) : null} +
)} diff --git a/packages/editor/src/components/ui/controls/slider-control.tsx b/packages/editor/src/components/ui/controls/slider-control.tsx index 1746cca3..937f637d 100644 --- a/packages/editor/src/components/ui/controls/slider-control.tsx +++ b/packages/editor/src/components/ui/controls/slider-control.tsx @@ -21,6 +21,20 @@ function stepPrecision(s: number): number { 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({ label, value, @@ -58,16 +72,14 @@ export function SliderControl({ if (isEditing) return e.preventDefault() const direction = e.deltaY < 0 ? 1 : -1 - let s = step - if (e.shiftKey) s = step * 10 - else if (e.altKey) s = step * 0.1 + const s = getAdjustedStep(step, e) const newValue = clamp(valueRef.current + direction * s) const final = Number.parseFloat(newValue.toFixed(stepPrecision(s))) if (final !== valueRef.current) onChange(final) } el.addEventListener('wheel', handleWheel, { passive: false }) return () => el.removeEventListener('wheel', handleWheel) - }, [isEditing, step, clamp, onChange, precision]) + }, [isEditing, step, clamp, onChange]) // Arrow key support while hovered useEffect(() => { @@ -78,9 +90,7 @@ export function SliderControl({ else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') direction = -1 if (direction !== 0) { e.preventDefault() - let s = step - if (e.shiftKey) s = step * 10 - else if (e.metaKey || e.ctrlKey) s = step * 0.1 + const s = getAdjustedStep(step, e) const newValue = clamp(valueRef.current + direction * s) const final = Number.parseFloat(newValue.toFixed(stepPrecision(s))) if (final !== valueRef.current) onChange(final) @@ -88,7 +98,7 @@ export function SliderControl({ } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [isHovered, isEditing, step, clamp, onChange, precision]) + }, [isHovered, isEditing, step, clamp, onChange]) const handleLabelPointerDown = useCallback( (e: React.PointerEvent) => { @@ -107,16 +117,14 @@ export function SliderControl({ if (!dragRef.current) return const { startX, startValue } = dragRef.current const dx = e.clientX - startX - let s = step - if (e.shiftKey) s = step * 10 - else if (e.metaKey || e.ctrlKey) s = step * 0.1 + const s = getAdjustedStep(step, e) // 4 px per step at default sensitivity const newValue = clamp( Number.parseFloat((startValue + (dx / 4) * s).toFixed(stepPrecision(s))), ) onChange(newValue) }, - [step, precision, clamp, onChange], + [step, clamp, onChange], ) const handleLabelPointerUp = useCallback( @@ -163,12 +171,18 @@ export function SliderControl({ setIsEditing(false) } else if (e.key === 'ArrowUp') { e.preventDefault() - const newV = clamp(value + step) + const adjustedStep = getAdjustedStep(step, e) + const newV = clamp( + Number.parseFloat((value + adjustedStep).toFixed(stepPrecision(adjustedStep))), + ) onChange(newV) setInputValue(newV.toFixed(precision)) } else if (e.key === 'ArrowDown') { e.preventDefault() - const newV = clamp(value - step) + const adjustedStep = getAdjustedStep(step, e) + const newV = clamp( + Number.parseFloat((value - adjustedStep).toFixed(stepPrecision(adjustedStep))), + ) onChange(newV) setInputValue(newV.toFixed(precision)) } diff --git a/packages/editor/src/components/ui/panels/ceiling-panel.tsx b/packages/editor/src/components/ui/panels/ceiling-panel.tsx index 43365ec1..ee9991eb 100755 --- a/packages/editor/src/components/ui/panels/ceiling-panel.tsx +++ b/packages/editor/src/components/ui/panels/ceiling-panel.tsx @@ -1,13 +1,12 @@ '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 { Edit, Move, Plus, Trash2 } from 'lucide-react' import { useCallback, useEffect } 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 { PanelSection } from '../controls/panel-section' import { SliderControl } from '../controls/slider-control' import { PanelWrapper } from './panel-wrapper' @@ -32,20 +31,6 @@ export function CeilingPanel() { [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(() => { setSelection({ selectedIds: [] }) setEditingHole(null) @@ -257,15 +242,6 @@ export function CeilingPanel() {
- - - } label="Move" onClick={handleMove} /> diff --git a/packages/editor/src/components/ui/panels/door-panel.tsx b/packages/editor/src/components/ui/panels/door-panel.tsx index 4c098d72..e02f13c7 100755 --- a/packages/editor/src/components/ui/panels/door-panel.tsx +++ b/packages/editor/src/components/ui/panels/door-panel.tsx @@ -5,7 +5,6 @@ import { type AnyNodeId, DoorNode, emitter, - type MaterialSchema, useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' @@ -15,7 +14,6 @@ import { usePresetsAdapter } from '../../../contexts/presets-context' 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 { MetricControl } from '../controls/metric-control' import { PanelSection } from '../controls/panel-section' import { SegmentedControl } from '../controls/segmented-control' @@ -46,13 +44,6 @@ export function DoorPanel() { [selectedId, updateNode], ) - const handleMaterialChange = useCallback( - (material: MaterialSchema) => { - handleUpdate({ material }) - }, - [handleUpdate], - ) - const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) }, [setSelection]) @@ -592,9 +583,6 @@ export function DoorPanel() { /> - - - ) } diff --git a/packages/editor/src/components/ui/panels/fence-panel.tsx b/packages/editor/src/components/ui/panels/fence-panel.tsx index 4e2785da..cb900dae 100644 --- a/packages/editor/src/components/ui/panels/fence-panel.tsx +++ b/packages/editor/src/components/ui/panels/fence-panel.tsx @@ -1,8 +1,25 @@ '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 { Move, Spline } from 'lucide-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 { PanelSection } from '../controls/panel-section' import { SegmentedControl } from '../controls/segmented-control' @@ -28,6 +45,8 @@ export function FencePanel() { const selectedCount = useViewer((s) => s.selection.selectedIds.length) const setSelection = useViewer((s) => s.setSelection) const updateNode = useScene((s) => s.updateNode) + const setMovingNode = useEditor((s) => s.setMovingNode) + const setCurvingFence = useEditor((s) => s.setCurvingFence) const node = useScene((s) => selectedId ? (s.nodes[selectedId as AnyNode['id']] as FenceNode | undefined) : undefined, @@ -67,25 +86,15 @@ export function FencePanel() { setSelection({ selectedIds: [] }) }, [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 - const dx = node.end[0] - node.start[0] - const dz = node.end[1] - node.start[1] - const length = Math.sqrt(dx * dx + dz * dz) + const length = getWallCurveLength(node) + const curveOffset = getClampedWallCurveOffset(node) + const maxCurveOffset = getMaxWallCurveOffset(node) return ( + handleUpdate({ curveOffset: normalizeWallCurveOffset(node, value) })} + precision={2} + step={0.1} + unit="m" + value={Math.round(curveOffset * 100) / 100} + /> - - - - ) } diff --git a/packages/editor/src/components/ui/panels/paint-panel.tsx b/packages/editor/src/components/ui/panels/paint-panel.tsx new file mode 100644 index 00000000..ffa8ec9d --- /dev/null +++ b/packages/editor/src/components/ui/panels/paint-panel.tsx @@ -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, + nextTransparent = currentProps.transparent, + ) => { + setActivePaintMaterial({ + material: { + preset: 'custom', + properties: { + ...currentProps, + ...updates, + transparent: nextTransparent, + }, + }, + sourceTarget: activePaintMaterial?.sourceTarget ?? activePaintTarget, + }) + } + + return ( + setPaintPanelOpen(false)} + title="Material" + width={320} + > + +
+
+ +
+ updateCustomMaterial({ color: e.target.value })} + type="color" + value={currentProps.color} + /> + updateCustomMaterial({ color: e.target.value })} + value={currentProps.color} + /> +
+
+ +
+
+ + + {currentProps.roughness.toFixed(2)} + +
+ updateCustomMaterial({ roughness: Number.parseFloat(e.target.value) })} + step={0.01} + type="range" + value={currentProps.roughness} + /> +
+ +
+
+ + + {currentProps.metalness.toFixed(2)} + +
+ updateCustomMaterial({ metalness: Number.parseFloat(e.target.value) })} + step={0.01} + type="range" + value={currentProps.metalness} + /> +
+ +
+
+ + + {currentProps.opacity.toFixed(2)} + +
+ { + const opacity = Number.parseFloat(e.target.value) + updateCustomMaterial({ opacity }, opacity < 1 || currentProps.transparent) + }} + step={0.01} + type="range" + value={currentProps.opacity} + /> +
+ +
+ + +
+
+
+
+ ) +} diff --git a/packages/editor/src/components/ui/panels/panel-manager.tsx b/packages/editor/src/components/ui/panels/panel-manager.tsx index 1750c6fc..cc20f364 100755 --- a/packages/editor/src/components/ui/panels/panel-manager.tsx +++ b/packages/editor/src/components/ui/panels/panel-manager.tsx @@ -7,6 +7,7 @@ import { CeilingPanel } from './ceiling-panel' import { DoorPanel } from './door-panel' import { FencePanel } from './fence-panel' import { ItemPanel } from './item-panel' +import { PaintPanel } from './paint-panel' import { ReferencePanel } from './reference-panel' import { RoofPanel } from './roof-panel' import { RoofSegmentPanel } from './roof-segment-panel' @@ -19,6 +20,9 @@ import { WindowPanel } from './window-panel' export function PanelManager() { const selectedIds = useViewer((s) => s.selection.selectedIds) 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 // so we don't re-render on unrelated scene mutations. const selectedNodeType = useScene((s) => { @@ -32,6 +36,15 @@ export function PanelManager() { return } + if ( + isPaintPanelOpen && + mode === 'material-paint' && + activePaintMaterial?.material?.properties && + !activePaintMaterial.materialPreset + ) { + return + } + // Show appropriate panel based on selected node type if (selectedNodeType) { switch (selectedNodeType) { diff --git a/packages/editor/src/components/ui/panels/roof-panel.tsx b/packages/editor/src/components/ui/panels/roof-panel.tsx index db344ef3..adab45b3 100755 --- a/packages/editor/src/components/ui/panels/roof-panel.tsx +++ b/packages/editor/src/components/ui/panels/roof-panel.tsx @@ -3,7 +3,6 @@ import { type AnyNode, type AnyNodeId, - type MaterialSchema, type RoofNode, RoofNode as RoofNodeSchema, type RoofSegmentNode, @@ -17,7 +16,6 @@ import { useShallow } from 'zustand/react/shallow' 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 { PanelSection } from '../controls/panel-section' import { SliderControl } from '../controls/slider-control' import { PanelWrapper } from './panel-wrapper' @@ -50,20 +48,6 @@ export function RoofPanel() { [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(() => { setSelection({ selectedIds: [] }) }, [setSelection]) @@ -170,11 +154,13 @@ export function RoofPanel() { ))} - } - label="Add Segment" - onClick={handleAddSegment} - /> + + } + label="Add Segment" + onClick={handleAddSegment} + /> + @@ -266,15 +252,6 @@ export function RoofPanel() { /> - - - ) } diff --git a/packages/editor/src/components/ui/panels/roof-segment-panel.tsx b/packages/editor/src/components/ui/panels/roof-segment-panel.tsx index de2a1440..88c1887e 100755 --- a/packages/editor/src/components/ui/panels/roof-segment-panel.tsx +++ b/packages/editor/src/components/ui/panels/roof-segment-panel.tsx @@ -3,7 +3,6 @@ import { type AnyNode, type AnyNodeId, - type MaterialSchema, type RoofSegmentNode, RoofSegmentNode as RoofSegmentNodeSchema, type RoofType, @@ -15,7 +14,6 @@ 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 { PanelSection } from '../controls/panel-section' import { SegmentedControl } from '../controls/segmented-control' import { SliderControl } from '../controls/slider-control' @@ -52,20 +50,6 @@ export function RoofSegmentPanel() { [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(() => { setSelection({ selectedIds: [] }) }, [setSelection]) @@ -322,15 +306,6 @@ export function RoofSegmentPanel() { /> - - - ) } diff --git a/packages/editor/src/components/ui/panels/slab-panel.tsx b/packages/editor/src/components/ui/panels/slab-panel.tsx index bdd4071f..b44b1a64 100755 --- a/packages/editor/src/components/ui/panels/slab-panel.tsx +++ b/packages/editor/src/components/ui/panels/slab-panel.tsx @@ -1,13 +1,12 @@ '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 { Edit, Move, Plus, Trash2 } from 'lucide-react' import { useCallback, useEffect } 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 { PanelSection } from '../controls/panel-section' import { SliderControl } from '../controls/slider-control' import { PanelWrapper } from './panel-wrapper' @@ -32,20 +31,6 @@ export function SlabPanel() { [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(() => { setSelection({ selectedIds: [] }) setEditingHole(null) @@ -257,15 +242,6 @@ export function SlabPanel() { /> - - - } label="Move" onClick={handleMove} /> diff --git a/packages/editor/src/components/ui/panels/stair-panel.tsx b/packages/editor/src/components/ui/panels/stair-panel.tsx index cfe51504..362c42aa 100644 --- a/packages/editor/src/components/ui/panels/stair-panel.tsx +++ b/packages/editor/src/components/ui/panels/stair-panel.tsx @@ -4,7 +4,6 @@ import { type AnyNode, type AnyNodeId, type LevelNode, - type MaterialSchema, type StairNode, type StairRailingMode, type StairSlabOpeningMode, @@ -23,7 +22,6 @@ import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '../../tools/stair/stair-defaults' import { ActionButton, ActionGroup } from '../controls/action-button' -import { MaterialPicker } from '../controls/material-picker' import { MetricControl } from '../controls/metric-control' import { PanelSection } from '../controls/panel-section' import { SegmentedControl } from '../controls/segmented-control' @@ -92,20 +90,6 @@ export function StairPanel() { [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(() => { setSelection({ selectedIds: [] }) }, [setSelection]) @@ -568,15 +552,6 @@ export function StairPanel() { /> - - - ) } diff --git a/packages/editor/src/components/ui/panels/stair-segment-panel.tsx b/packages/editor/src/components/ui/panels/stair-segment-panel.tsx index 17c84650..c783bd98 100644 --- a/packages/editor/src/components/ui/panels/stair-segment-panel.tsx +++ b/packages/editor/src/components/ui/panels/stair-segment-panel.tsx @@ -4,7 +4,6 @@ import { type AnyNode, type AnyNodeId, type AttachmentSide, - type MaterialSchema, type StairSegmentNode, StairSegmentNode as StairSegmentNodeSchema, type StairSegmentType, @@ -16,7 +15,6 @@ 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 { PanelSection } from '../controls/panel-section' import { SegmentedControl } from '../controls/segmented-control' import { SliderControl } from '../controls/slider-control' @@ -61,20 +59,6 @@ export function StairSegmentPanel() { [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(() => { setSelection({ selectedIds: [] }) }, [setSelection]) @@ -336,15 +320,6 @@ export function StairSegmentPanel() { /> - - - ) } diff --git a/packages/editor/src/components/ui/panels/wall-panel.tsx b/packages/editor/src/components/ui/panels/wall-panel.tsx index 066a9059..c1fae2e7 100755 --- a/packages/editor/src/components/ui/panels/wall-panel.tsx +++ b/packages/editor/src/components/ui/panels/wall-panel.tsx @@ -7,7 +7,6 @@ import { getMaxWallCurveOffset, getWallCurveLength, normalizeWallCurveOffset, - type MaterialSchema, useScene, type WallNode, } from '@pascal-app/core' @@ -17,7 +16,6 @@ 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 { PanelSection } from '../controls/panel-section' import { SliderControl } from '../controls/slider-control' import { PanelWrapper } from './panel-wrapper' @@ -81,20 +79,6 @@ export function WallPanel() { [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(() => { setSelection({ selectedIds: [] }) }, [setSelection]) @@ -169,33 +153,25 @@ export function WallPanel() { min={-Math.max(0.01, maxCurveOffset)} onChange={(v) => handleUpdate({ curveOffset: normalizeWallCurveOffset(node, v) })} precision={2} - step={0.01} + step={0.1} unit="m" value={Math.round(curveOffset * 100) / 100} /> )} - - + + + } label="Move" onClick={handleMove} /> + {!hasWallChildrenBlockingCurve && ( + } + label="Curve" + onClick={handleCurve} + /> + )} + - - - } label="Move" onClick={handleMove} /> - {!hasWallChildrenBlockingCurve && ( - } - label="Curve" - onClick={handleCurve} - /> - )} - ) } diff --git a/packages/editor/src/components/ui/panels/window-panel.tsx b/packages/editor/src/components/ui/panels/window-panel.tsx index cc346cf0..a41891b0 100755 --- a/packages/editor/src/components/ui/panels/window-panel.tsx +++ b/packages/editor/src/components/ui/panels/window-panel.tsx @@ -4,7 +4,6 @@ import { type AnyNode, type AnyNodeId, emitter, - type MaterialSchema, useScene, WindowNode, } from '@pascal-app/core' @@ -15,7 +14,6 @@ import { usePresetsAdapter } from '../../../contexts/presets-context' 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 { MetricControl } from '../controls/metric-control' import { PanelSection } from '../controls/panel-section' import { SliderControl } from '../controls/slider-control' @@ -45,13 +43,6 @@ export function WindowPanel() { [selectedId, updateNode], ) - const handleMaterialChange = useCallback( - (material: MaterialSchema) => { - handleUpdate({ material }) - }, - [handleUpdate], - ) - const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) }, [setSelection]) @@ -431,9 +422,6 @@ export function WindowPanel() { /> - - - ) } diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index 511075d9..d0eecc69 100755 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -1,6 +1,7 @@ import { type AnyNodeId, emitter, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useEffect } from 'react' +import { runRedo, runUndo } from '../lib/history' import { sfxEmitter } from '../lib/sfx-bus' import useEditor from '../store/use-editor' @@ -88,14 +89,21 @@ export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => { if (isVersionPreviewMode) return e.preventDefault() 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)) { if (isVersionPreviewMode) return e.preventDefault() - useScene.temporal.getState().undo() + runUndo() } else if (e.key === 'Z' && e.shiftKey && (e.metaKey || e.ctrlKey)) { if (isVersionPreviewMode) return e.preventDefault() - useScene.temporal.getState().redo() + runRedo() } else if (e.key === 'ArrowUp' && (e.metaKey || e.ctrlKey)) { e.preventDefault() const { buildingId, levelId } = useViewer.getState().selection diff --git a/packages/editor/src/lib/history.ts b/packages/editor/src/lib/history.ts new file mode 100644 index 00000000..bdd37fbb --- /dev/null +++ b/packages/editor/src/lib/history.ts @@ -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() +} diff --git a/packages/editor/src/lib/material-paint.ts b/packages/editor/src/lib/material-paint.ts new file mode 100644 index 00000000..ffdde786 --- /dev/null +++ b/packages/editor/src/lib/material-paint.ts @@ -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 { + 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 { + 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 { + 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( + material: MaterialSchema | undefined, + materialPreset: string | undefined, +): Partial { + return { + material, + materialPreset, + } as Partial +} + +export function resolveActivePaintMaterialFromSelection(params: { + nodes: Record + 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 + 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 +} diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 6fa9bb4b..f6d0488b 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -1,7 +1,8 @@ 'use client' -import type { AssetInput } from '@pascal-app/core' import { + type AnyNodeId, + type AssetInput, type BuildingNode, type CeilingNode, type DoorNode, @@ -10,18 +11,28 @@ import { type LevelNode, type RoofNode, type RoofSegmentNode, + type RoofSurfaceMaterialRole, type SlabNode, type Space, type StairNode, type StairSegmentNode, + type StairSurfaceMaterialRole, useScene, type WallNode, + type WallSurfaceSide, type WindowNode, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { create } from 'zustand' import { persist } from 'zustand/middleware' 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_FLOORPLAN_PANE_RATIO = 0.5 @@ -33,7 +44,7 @@ export type SplitOrientation = 'horizontal' | 'vertical' 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) export type StructureTool = @@ -80,6 +91,24 @@ export type MovingWallEndpoint = { 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 = { phase: Phase setPhase: (phase: Phase) => void @@ -125,8 +154,23 @@ type EditorState = { ) => void movingWallEndpoint: MovingWallEndpoint | null setMovingWallEndpoint: (value: MovingWallEndpoint | null) => void + movingFenceEndpoint: MovingFenceEndpoint | null + setMovingFenceEndpoint: (value: MovingFenceEndpoint | null) => void curvingWall: WallNode | null 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 setSelectedReferenceId: (id: string | null) => void // Space detection for cutaway mode @@ -206,7 +250,7 @@ function normalizeModeForPhase(phase: Phase, mode: Mode | undefined): Mode { return 'select' } - return mode === 'build' || mode === 'delete' ? mode : 'select' + return mode === 'build' || mode === 'delete' || mode === 'material-paint' ? mode : 'select' } function normalizeFloorplanPaneRatio(value: unknown): number { @@ -444,6 +488,8 @@ const useEditor = create()( const category = get().catalogCategory ?? 'furniture' set({ selectedItem: getDefaultSelectedItemForCategory(category) }) } + } else if (mode === 'material-paint') { + get().primeMaterialPaintFromSelection() } // When leaving build mode, clear tool else if (tool) { @@ -500,8 +546,55 @@ const useEditor = create()( setMovingNode: (node) => set({ movingNode: node }), movingWallEndpoint: null, setMovingWallEndpoint: (value) => set({ movingWallEndpoint: value }), + movingFenceEndpoint: null, + setMovingFenceEndpoint: (value) => set({ movingFenceEndpoint: value }), curvingWall: null, 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, setSelectedReferenceId: (id) => set({ selectedReferenceId: id }), spaces: {}, diff --git a/packages/viewer/package.json b/packages/viewer/package.json index 68e98d9b..1c214282 100644 --- a/packages/viewer/package.json +++ b/packages/viewer/package.json @@ -1,6 +1,6 @@ { "name": "@pascal-app/viewer", - "version": "0.5.1", + "version": "0.6.0", "description": "3D viewer component for Pascal building editor", "type": "module", "main": "./dist/index.js", @@ -22,7 +22,7 @@ "prepublishOnly": "npm run build" }, "peerDependencies": { - "@pascal-app/core": "^0.5.1", + "@pascal-app/core": "^0.6.0", "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", diff --git a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx index 51e17f4a..91e7487c 100644 --- a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx +++ b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx @@ -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 { float, mix, positionWorld, smoothstep } from 'three/tsl' import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu' @@ -32,6 +37,18 @@ function createCeilingMaterials(color = '#999999') { return { topMaterial, bottomMaterial } } +const ceilingMaterialCache = new Map>() + +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 }) => { const ref = useRef(null!) @@ -42,8 +59,14 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { const preset = getMaterialPresetByRef(node.materialPreset) const props = preset?.mapProperties ?? resolveMaterial(node.material) const color = props.color || '#999999' - return createCeilingMaterials(color) - }, [node.materialPreset, node.material, node.material?.preset, node.material?.properties, node.material?.texture]) + return getCeilingMaterials(color) + }, [ + node.materialPreset, + node.material, + node.material?.preset, + node.material?.properties, + node.material?.texture, + ]) return ( diff --git a/packages/viewer/src/components/renderers/fence/fence-renderer.tsx b/packages/viewer/src/components/renderers/fence/fence-renderer.tsx index 7f22d5e8..4e563572 100644 --- a/packages/viewer/src/components/renderers/fence/fence-renderer.tsx +++ b/packages/viewer/src/components/renderers/fence/fence-renderer.tsx @@ -31,7 +31,14 @@ export const FenceRenderer = ({ node }: { node: FenceNode }) => { }, [node.id]) return ( - + ) diff --git a/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx b/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx index b24d5b9c..8bd052fa 100644 --- a/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx +++ b/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx @@ -1,9 +1,15 @@ -import { type AnyNodeId, type RoofNode, type RoofSegmentNode, useRegistry, useScene } from '@pascal-app/core' -import { useMemo, useRef } from 'react' -import type * as THREE from 'three' +import { + type AnyNodeId, + 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 { createMaterial, createMaterialFromPresetRef } from '../../../lib/materials' import useViewer from '../../../store/use-viewer' +import { getRoofMaterialArray } from '../../../systems/roof/roof-materials' import { roofDebugMaterials, roofMaterials } from '../roof/roof-materials' export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => { @@ -14,44 +20,44 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => { const handlers = useNodeEvents(node, 'roof-segment') const debugColors = useViewer((s) => s.debugColors) - const parentNode = - node.parentId ? (nodes[node.parentId as AnyNodeId] as RoofNode | undefined) : undefined + const parentNode = node.parentId + ? (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 effectiveMaterialPreset = node.materialPreset ?? parentNode?.materialPreset - const effectiveMaterial = node.material ?? parentNode?.material + if (node.material !== undefined || typeof node.materialPreset === 'string') { + return null + } - const presetMaterial = createMaterialFromPresetRef(effectiveMaterialPreset) - if (presetMaterial) return presetMaterial - 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, - ]) + return parentNode ? getRoofMaterialArray(parentNode) : null + }, [node, parentNode]) const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials + useEffect(() => { + return () => { + placeholderGeometry.dispose() + } + }, [placeholderGeometry]) + return ( - {/* RoofSystem will replace this geometry in the next frame */} - - + /> ) } diff --git a/packages/viewer/src/components/renderers/roof/roof-renderer.tsx b/packages/viewer/src/components/renderers/roof/roof-renderer.tsx index 776fcbe7..1dee2a98 100644 --- a/packages/viewer/src/components/renderers/roof/roof-renderer.tsx +++ b/packages/viewer/src/components/renderers/roof/roof-renderer.tsx @@ -1,9 +1,9 @@ import { type RoofNode, useRegistry } from '@pascal-app/core' -import { useMemo, useRef } from 'react' -import type * as THREE from 'three' +import { useEffect, useMemo, useRef } from 'react' +import * as THREE from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' -import { createMaterial, createMaterialFromPresetRef } from '../../../lib/materials' import useViewer from '../../../store/use-viewer' +import { getRoofMaterialArray } from '../../../systems/roof/roof-materials' import { NodeRenderer } from '../node-renderer' import { roofDebugMaterials, roofMaterials } from './roof-materials' @@ -14,17 +14,26 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => { const handlers = useNodeEvents(node, 'roof') 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 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 customMaterial = useMemo(() => getRoofMaterialArray(node), [node]) const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials + useEffect(() => { + return () => { + placeholderGeometry.dispose() + } + }, [placeholderGeometry]) + return ( { visible={node.visible} {...handlers} > - - - + {(node.children ?? []).map((childId) => ( diff --git a/packages/viewer/src/components/renderers/site/site-renderer.tsx b/packages/viewer/src/components/renderers/site/site-renderer.tsx index e52b6c4c..c16e056a 100644 --- a/packages/viewer/src/components/renderers/site/site-renderer.tsx +++ b/packages/viewer/src/components/renderers/site/site-renderer.tsx @@ -55,7 +55,16 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => { }) 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) => { if (!Number.isFinite(lowestLevelIndex)) return true const parentLevel = n.parentId ? levelIndexById.get(n.parentId as string) : undefined diff --git a/packages/viewer/src/components/renderers/slab/slab-renderer.tsx b/packages/viewer/src/components/renderers/slab/slab-renderer.tsx index 537433fe..e2b935f6 100644 --- a/packages/viewer/src/components/renderers/slab/slab-renderer.tsx +++ b/packages/viewer/src/components/renderers/slab/slab-renderer.tsx @@ -1,14 +1,50 @@ -import { type SlabNode, useRegistry } from '@pascal-app/core' -import { useEffect, useMemo, useRef } from 'react' -import * as THREE from 'three' +import { getMaterialPresetByRef, type SlabNode, useRegistry } from '@pascal-app/core' +import { useMemo, useRef } from 'react' import type { Mesh } from 'three' +import * as THREE from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' import { + applyMaterialPresetToMaterials, createMaterial, - createMaterialFromPresetRef, DEFAULT_SLAB_MATERIAL, } from '../../../lib/materials' +const slabMaterialCache = new Map() + +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 }) => { const ref = useRef(null!) @@ -17,21 +53,17 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => { const handlers = useNodeEvents(node, 'slab') const material = useMemo(() => { - const presetMaterial = createMaterialFromPresetRef(node.materialPreset) - const sourceMaterial = presetMaterial ?? (node.material ? createMaterial(node.material) : DEFAULT_SLAB_MATERIAL) - const slabMaterial = sourceMaterial.clone() + const resolvedMaterial = node.material + const resolvedMaterialPreset = node.materialPreset + const cacheKey = JSON.stringify({ + material: resolvedMaterial ?? null, + materialPreset: resolvedMaterialPreset ?? null, + }) - // 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 - - return slabMaterial + return getSlabMaterial(cacheKey, { + material: resolvedMaterial, + materialPreset: resolvedMaterialPreset, + }) }, [ node.material, node.material?.preset, @@ -40,12 +72,6 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => { node.materialPreset, ]) - useEffect(() => { - return () => { - material.dispose() - } - }, [material]) - return ( { const ref = useRef(null!) @@ -15,42 +21,37 @@ export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => { }, [node.id]) const handlers = useNodeEvents(node, 'stair-segment') - const parentNode = - node.parentId ? (nodes[node.parentId as AnyNodeId] as StairNode | undefined) : undefined + const parentNode = node.parentId + ? (nodes[node.parentId as AnyNodeId] as StairNode | undefined) + : undefined + const material = useMemo( + () => getStraightStairSegmentBodyMaterials(node, parentNode), + [node, parentNode], + ) - const material = useMemo(() => { - const effectiveMaterialPreset = node.materialPreset ?? parentNode?.materialPreset - const effectiveMaterial = node.material ?? parentNode?.material + 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) + return geometry + }, []) - const presetMaterial = createMaterialFromPresetRef(effectiveMaterialPreset) - if (presetMaterial) return presetMaterial - const mat = effectiveMaterial - if (!mat) return DEFAULT_STAIR_MATERIAL - 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, - ]) + useEffect(() => { + return () => { + placeholderGeometry.dispose() + } + }, [placeholderGeometry]) return ( - {/* StairSystem will replace this geometry in the next frame */} - - + /> ) } diff --git a/packages/viewer/src/components/renderers/stair/stair-renderer.tsx b/packages/viewer/src/components/renderers/stair/stair-renderer.tsx index 0eb5795f..d05ed49c 100644 --- a/packages/viewer/src/components/renderers/stair/stair-renderer.tsx +++ b/packages/viewer/src/components/renderers/stair/stair-renderer.tsx @@ -5,7 +5,7 @@ import { useRegistry, useScene, } from '@pascal-app/core' -import { useLayoutEffect, useMemo, useRef } from 'react' +import { useEffect, useLayoutEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' import { @@ -13,6 +13,11 @@ import { createMaterialFromPresetRef, DEFAULT_STAIR_MATERIAL, } from '../../../lib/materials' +import { + getStairBodyMaterials, + getStairRailingMaterial, + type StairBodyMaterials, +} from '../../../systems/stair/stair-materials' import { NodeRenderer } from '../node-renderer' type SegmentTransform = { @@ -71,6 +76,24 @@ export const StairRenderer = ({ node }: { node: StairNode }) => { 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 ( { {...handlers} > {isSegmentBasedStair ? ( - - - + ) : null} - {!isSegmentBasedStair ? : null} - + {!isSegmentBasedStair ? ( + + ) : null} + {isSegmentBasedStair ? ( {(node.children ?? []).map((childId) => ( @@ -170,6 +199,7 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE. geometry={BALUSTER_GEOMETRY} key={`${stair.id}-curved-baluster-${sideIndex}-${pointIndex}`} material={material} + name="stair-railing-baluster" position={[point[0], point[1] + railHeight / 2, point[2]]} receiveShadow scale={[balusterRadius, railHeight, balusterRadius]} @@ -227,6 +257,7 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE. geometry={BALUSTER_GEOMETRY} key={`${segmentPath.layout.segment.id}-${sidePath.side}-baluster-${pointIndex}`} material={material} + name="stair-railing-baluster" position={[point[2], point[1] + railHeight / 2, point[0]]} receiveShadow 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 RAIL_GEOMETRY = new THREE.CylinderGeometry(1, 1, 1, 8) +const STAIR_TREAD_MATERIAL_INDEX = 0 +const STAIR_SIDE_MATERIAL_INDEX = 1 function RailSegment({ start, @@ -367,6 +400,7 @@ function RailSegment({ castShadow geometry={RAIL_GEOMETRY} material={material} + name="stair-railing-rail" position={[midpoint.x, midpoint.y, midpoint.z]} quaternion={quaternion} 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 totalRise = Math.max(stair.totalRise ?? 2.5, 0.1) const stepHeight = totalRise / stepCount @@ -411,7 +452,8 @@ function CurvedStairBody({ stair, material }: { stair: StairNode; material: THRE @@ -556,15 +599,39 @@ function buildCurvedStepGeometry( const positions: number[] = [] const normals: number[] = [] + const uvs: number[] = [] + const triangleMaterialIndices: number[] = [] const pointOnArc = (radius: number, angle: number, y: number) => 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 = ( a: THREE.Vector3, b: THREE.Vector3, c: THREE.Vector3, normal: THREE.Vector3, + materialIndex: number, ) => { const edgeAB = b.clone().sub(a) const edgeAC = c.clone().sub(a) @@ -573,7 +640,9 @@ function buildCurvedStepGeometry( for (const point of ordered) { positions.push(point.x, point.y, point.z) normals.push(normal.x, normal.y, normal.z) + pushUv(point, normal, materialIndex) } + triangleMaterialIndices.push(materialIndex) } const pushQuad = ( @@ -582,9 +651,10 @@ function buildCurvedStepGeometry( c: THREE.Vector3, d: THREE.Vector3, normal: THREE.Vector3, + materialIndex: number, ) => { - pushTriangle(a, b, c, normal) - pushTriangle(a, c, d, normal) + pushTriangle(a, b, c, normal, materialIndex) + pushTriangle(a, c, d, normal, materialIndex) } 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 innerNormal = new THREE.Vector3(-Math.cos(midAngle), 0, -Math.sin(midAngle)).normalize() - pushQuad(innerStartTop, outerStartTop, outerEndTop, innerEndTop, upNormal) - pushQuad(innerStartBottom, innerEndBottom, outerEndBottom, outerStartBottom, downNormal) - pushQuad(innerStartBottom, innerStartTop, innerEndTop, innerEndBottom, innerNormal) - pushQuad(outerStartBottom, outerEndBottom, outerEndTop, outerStartTop, outerNormal) + pushQuad( + innerStartTop, + outerStartTop, + 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) @@ -634,12 +732,49 @@ function buildCurvedStepGeometry( sweepDirection * Math.cos(endAngle), ).normalize() - pushQuad(startInnerBottom, startOuterBottom, startOuterTop, startInnerTop, startNormal) - pushQuad(endInnerBottom, endInnerTop, endOuterTop, endOuterBottom, endNormal) + pushQuad( + startInnerBottom, + startOuterBottom, + startOuterTop, + startInnerTop, + startNormal, + STAIR_SIDE_MATERIAL_INDEX, + ) + pushQuad( + endInnerBottom, + endInnerTop, + endOuterTop, + endOuterBottom, + endNormal, + STAIR_SIDE_MATERIAL_INDEX, + ) const geometry = new THREE.BufferGeometry() geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 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() return geometry } diff --git a/packages/viewer/src/components/renderers/wall/wall-renderer.tsx b/packages/viewer/src/components/renderers/wall/wall-renderer.tsx index ef6f70cf..9ff38006 100644 --- a/packages/viewer/src/components/renderers/wall/wall-renderer.tsx +++ b/packages/viewer/src/components/renderers/wall/wall-renderer.tsx @@ -1,12 +1,8 @@ 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 { useNodeEvents } from '../../../hooks/use-node-events' -import { - createMaterial, - createMaterialFromPresetRef, - DEFAULT_WALL_MATERIAL, -} from '../../../lib/materials' +import { getVisibleWallMaterials } from '../../../systems/wall/wall-materials' import { NodeRenderer } from '../node-renderer' export const WallRenderer = ({ node }: { node: WallNode }) => { @@ -19,20 +15,7 @@ export const WallRenderer = ({ node }: { node: WallNode }) => { }, [node.id]) const handlers = useNodeEvents(node, 'wall') - - 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, - ]) + const material = getVisibleWallMaterials(node) return ( diff --git a/packages/viewer/src/components/viewer/ground-occluder.tsx b/packages/viewer/src/components/viewer/ground-occluder.tsx index c04e9d81..54be8b51 100644 --- a/packages/viewer/src/components/viewer/ground-occluder.tsx +++ b/packages/viewer/src/components/viewer/ground-occluder.tsx @@ -38,7 +38,15 @@ export const GroundOccluder = () => { const polygons: [number, number][][] = [] 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 } diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 42182d9a..6ee40f54 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -26,7 +26,7 @@ import { SceneRenderer } from '../renderers/scene-renderer' import FrameLimiter from './frame-limiter' import { Lights } from './lights' 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 { ViewerCamera } from './viewer-camera' @@ -101,12 +101,14 @@ function GPUDeviceWatcher() { interface ViewerProps { children?: React.ReactNode + hoverStyles?: HoverStyles selectionManager?: 'default' | 'custom' perf?: boolean } const Viewer: React.FC = ({ children, + hoverStyles = DEFAULT_HOVER_STYLES, selectionManager = 'default', perf = false, }) => { @@ -165,7 +167,7 @@ const Viewer: React.FC = ({ - + {/* */} diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index a0b3022e..96c8c690 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -47,6 +47,28 @@ const RETRY_DELAY_MS = 500 const DARK_BG = '#1f2433' const LIGHT_BG = '#ffffff' +export type HoverStyle = { + visibleColor: number + hiddenColor: number + strength: number + pulse: boolean +} + +export type HoverStyles = { + default: HoverStyle +} & Record + +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[]) { let nextIndex = 0 @@ -62,8 +84,12 @@ function sanitizeOutlineObjects(objects: Object3D[]) { objects.length = nextIndex } -const PostProcessingPasses = () => { - const { gl: renderer, scene, camera } = useThree() +const PostProcessingPasses = ({ + hoverStyles = DEFAULT_HOVER_STYLES, +}: { + hoverStyles?: HoverStyles +}) => { + const { gl: renderer, invalidate, scene, camera } = useThree() const renderPipelineRef = useRef(null) const hasPipelineErrorRef = useRef(false) const retryCountRef = useRef(0) @@ -83,6 +109,10 @@ const PostProcessingPasses = () => { return l }, []) 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 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 useEffect(() => { // Intentionally touch these so React/biome treat project switches and retry bumps @@ -248,18 +295,9 @@ const PostProcessingPasses = () => { .mul(selectedStrength) // 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 osc = - hoverHighlightMode === 'delete' - ? float(1) - : oscSine(time.div(pulsePeriod).mul(2)).mul(0.5).add(0.5) // [ 0.5, 1.0 ] + const oscillating = oscSine(time.div(pulsePeriod).mul(2)).mul(0.5).add(0.5) + const osc = mix(oscillating, float(1), hoverPulseMix) const hoverOutline = outlineNode.secondaryVisibleEdge .mul(hoverVisibleColor) .add(outlineNode.secondaryHiddenEdge.mul(hoverHiddenColor)) @@ -298,7 +336,18 @@ const PostProcessingPasses = () => { } renderPipelineRef.current = null } - }, [renderer, scene, camera, hoverHighlightMode, zoneLayers, projectId, pipelineVersion]) + }, [ + camera, + hoverHiddenColor, + hoverPulseMix, + hoverStrength, + hoverVisibleColor, + pipelineVersion, + projectId, + renderer, + scene, + zoneLayers, + ]) useFrame((_, delta) => { // Animate background colour toward the current theme target (same lerp as AnimatedBackground) diff --git a/packages/viewer/src/hooks/use-node-events.ts b/packages/viewer/src/hooks/use-node-events.ts index d247ece1..133ba34b 100644 --- a/packages/viewer/src/hooks/use-node-events.ts +++ b/packages/viewer/src/hooks/use-node-events.ts @@ -64,6 +64,8 @@ export function useNodeEvents(node: NodeConfig[T]['node'], t position: [e.point.x, e.point.y, e.point.z], localPosition: [localPoint.x, localPoint.y, localPoint.z], 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(), nativeEvent: e, } as NodeConfig[T]['event'] diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 799e1312..e867a444 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -1,12 +1,18 @@ 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 { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url' export { SCENE_LAYER, ZONE_LAYER } from './lib/layers' export { + applyMaterialPresetToMaterials, clearMaterialCache, createDefaultMaterial, createMaterial, + createMaterialFromPresetRef, DEFAULT_CEILING_MATERIAL, DEFAULT_DOOR_MATERIAL, DEFAULT_ROOF_MATERIAL, @@ -19,3 +25,6 @@ export { mergedOutline } from './lib/merged-outline-node' export { default as useViewer } from './store/use-viewer' export { InteractiveSystem } from './systems/interactive/interactive-system' 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' diff --git a/packages/viewer/src/store/use-viewer.d.ts b/packages/viewer/src/store/use-viewer.d.ts index 4dde28a6..1342032a 100644 --- a/packages/viewer/src/store/use-viewer.d.ts +++ b/packages/viewer/src/store/use-viewer.d.ts @@ -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' type SelectionPath = { buildingId: BuildingNode['id'] | null @@ -14,8 +20,8 @@ type ViewerState = { selection: SelectionPath previewSelectedIds: BaseNode['id'][] setPreviewSelectedIds: (ids: BaseNode['id'][]) => void - hoverHighlightMode: 'default' | 'delete' - setHoverHighlightMode: (mode: 'default' | 'delete') => void + hoverHighlightMode: string + setHoverHighlightMode: (mode: string) => void hoveredId: AnyNode['id'] | ZoneNode['id'] | null setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void cameraMode: 'perspective' | 'orthographic' diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index 070ec71d..00649a63 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -22,8 +22,8 @@ type ViewerState = { selection: SelectionPath previewSelectedIds: BaseNode['id'][] setPreviewSelectedIds: (ids: BaseNode['id'][]) => void - hoverHighlightMode: 'default' | 'delete' - setHoverHighlightMode: (mode: 'default' | 'delete') => void + hoverHighlightMode: string + setHoverHighlightMode: (mode: string) => void hoveredId: AnyNode['id'] | ZoneNode['id'] | null setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void @@ -85,9 +85,10 @@ const useViewer = create()( previewSelectedIds: [], setPreviewSelectedIds: (ids) => set({ previewSelectedIds: ids }), hoverHighlightMode: 'default', - setHoverHighlightMode: (mode) => set({ hoverHighlightMode: mode }), + setHoverHighlightMode: (mode) => + set((state) => (state.hoverHighlightMode === mode ? state : { hoverHighlightMode: mode })), hoveredId: null, - setHoveredId: (id) => set({ hoveredId: id }), + setHoveredId: (id) => set((state) => (state.hoveredId === id ? state : { hoveredId: id })), cameraMode: 'perspective', setCameraMode: (mode) => set({ cameraMode: mode }), diff --git a/packages/viewer/src/systems/roof/roof-materials.ts b/packages/viewer/src/systems/roof/roof-materials.ts new file mode 100644 index 00000000..8510cfa6 --- /dev/null +++ b/packages/viewer/src/systems/roof/roof-materials.ts @@ -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() + +function getSurfaceMaterialSignature( + spec: ReturnType, +): 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 +} diff --git a/packages/viewer/src/systems/stair/stair-materials.ts b/packages/viewer/src/systems/stair/stair-materials.ts new file mode 100644 index 00000000..fb63a628 --- /dev/null +++ b/packages/viewer/src/systems/stair/stair-materials.ts @@ -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() +const stairRailingMaterialCache = new Map() + +function getSurfaceMaterialSignature( + spec: ReturnType, +): 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] +} diff --git a/packages/viewer/src/systems/wall/wall-cutout.tsx b/packages/viewer/src/systems/wall/wall-cutout.tsx index b80613d1..cec20d01 100644 --- a/packages/viewer/src/systems/wall/wall-cutout.tsx +++ b/packages/viewer/src/systems/wall/wall-cutout.tsx @@ -1,210 +1,14 @@ -import { - type AnyNodeId, - baseMaterial, - emitter, - getMaterialPresetByRef, - sceneRegistry, - useScene, - type WallNode, -} from '@pascal-app/core' +import { type AnyNodeId, emitter, sceneRegistry, useScene, type WallNode } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' import { useEffect, useRef } from 'react' import type { Material } from 'three' -import { Color } from 'three' -import { Fn, float, fract, length, mix, positionLocal, smoothstep, step, vec2 } from 'three/tsl' -import { type Mesh, MeshStandardNodeMaterial, Vector3 } from 'three/webgpu' +import { type Mesh, Vector3 } from 'three/webgpu' import useViewer from '../../store/use-viewer' -import { createMaterial, createMaterialFromPresetRef } from '../../lib/materials' +import { getMaterialsForWall } from './wall-materials' const tmpVec = new Vector3() const u = 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() - -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( wallNode: WallNode, @@ -301,7 +105,7 @@ export const WallCutout = () => { ? materials.deleteVisible : isSelectionHighlighted ? materials.highlightedVisible - : getVisibleWallMaterial(wallNode) + : materials.visible } }) lastWallMode.current = wallMode @@ -311,7 +115,7 @@ export const WallCutout = () => { }) useEffect(() => { - const snapshot = new Map() + const snapshot = new Map() const restoreForCapture = () => { sceneRegistry.byType.wall.forEach((wallId) => { @@ -320,10 +124,10 @@ export const WallCutout = () => { const wallNode = useScene.getState().nodes[wallId as AnyNodeId] as WallNode | undefined if (!wallNode || wallNode.type !== 'wall') return const mats = getMaterialsForWall(wallNode) - const current = wallMesh.material as Material + const current = wallMesh.material as Material | Material[] snapshot.set(wallMesh, current) if (current === mats.highlightedVisible || current === mats.deleteVisible) { - wallMesh.material = getVisibleWallMaterial(wallNode) + wallMesh.material = mats.visible } else if (current === mats.highlightedInvisible || current === mats.deleteInvisible) { wallMesh.material = mats.invisible } diff --git a/packages/viewer/src/systems/wall/wall-materials.ts b/packages/viewer/src/systems/wall/wall-materials.ts new file mode 100644 index 00000000..5edccfea --- /dev/null +++ b/packages/viewer/src/systems/wall/wall-materials.ts @@ -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() + +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() + 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 +}