Merge branch 'main' into feat/2d-editor
This commit is contained in:
@@ -0,0 +1,119 @@
|
|||||||
|
---
|
||||||
|
name: review-architecture
|
||||||
|
description: Review a PR against the Pascal architectural rules — layer boundaries (core/viewer/editor), systems/renderers/tools separation, hook hygiene (useEditor/useScene/useViewer), and selector performance. Use when the user asks to review a PR, audit a branch, or check that changes respect the codebase's architecture.
|
||||||
|
allowed-tools: Bash(git *) Bash(gh *) Read Grep Glob
|
||||||
|
---
|
||||||
|
|
||||||
|
Architectural review for Pascal PRs. The user will provide a PR URL, branch name, or ask to review the current branch.
|
||||||
|
|
||||||
|
## 1. Load the rules (required — do not skip)
|
||||||
|
|
||||||
|
Read these before reviewing any diff. They are the source of truth, not your training data:
|
||||||
|
|
||||||
|
- `.claude/rules/systems.md` — core systems vs viewer systems, what each may do
|
||||||
|
- `.claude/rules/renderers.md` — renderer responsibilities and prohibitions
|
||||||
|
- `.claude/rules/tools.md` — editor tools live only in `apps/editor/components/tools/`
|
||||||
|
- `.claude/rules/viewer-isolation.md` — viewer must stay editor-agnostic
|
||||||
|
- `.claude/rules/layers.md`
|
||||||
|
- `.claude/rules/selection-managers.md`
|
||||||
|
- `.claude/rules/scene-registry.md`
|
||||||
|
- `.claude/rules/spatial-queries.md`
|
||||||
|
- `.claude/rules/node-schemas.md`
|
||||||
|
- `.claude/rules/events.md`
|
||||||
|
|
||||||
|
Only the first four are required on every review; read the rest when the diff touches their subject area.
|
||||||
|
|
||||||
|
## 2. Fetch the diff
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# If the user gave a PR URL or number:
|
||||||
|
gh pr diff <pr-number-or-url>
|
||||||
|
|
||||||
|
# If reviewing the current branch:
|
||||||
|
git diff main...HEAD
|
||||||
|
```
|
||||||
|
|
||||||
|
Also list changed files so you can map each to the relevant rule:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh pr view <pr> --json files --jq '.files[].path'
|
||||||
|
# or
|
||||||
|
git diff --name-only main...HEAD
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Layer classification — do this BEFORE the checklist
|
||||||
|
|
||||||
|
For every new file, new type, new store field, or new exported helper introduced by the diff, answer one question: **which layer does this belong to — core, viewer, or editor?** If the answer is "editor" but the code lives in `packages/core` or `packages/viewer` (or vice versa), flag it as a **blocker**. This is the most common and most damaging class of violation, and the checklist below won't reliably catch it on its own — do this pass explicitly.
|
||||||
|
|
||||||
|
### The three layers and what they own
|
||||||
|
|
||||||
|
**`packages/core` — domain data + pure logic.**
|
||||||
|
Owns: node schemas, the scene store (`useScene`), live transforms store, core systems (wall mitering, slab polygons, space detection), event bus, plain 2D/3D math helpers, `sceneRegistry`. Consumed by every downstream package, including read-only embeds. Must not know about: Three.js/R3F, `packages/viewer`, `apps/editor`, any rendering or UI concept, any tool/mode/phase concept, or any *view*-specific concept (floorplan, paint preview, cursor indicators, selection outline styling, etc.).
|
||||||
|
|
||||||
|
**`packages/viewer` — the 3D canvas, shippable standalone.**
|
||||||
|
Owns: `<Viewer>`, renderers, viewer systems (cutouts, zones, level positions, scans), the viewer store (`useViewer`) *for genuine presentation state only* (selection path, camera/level/wall/view modes, theme, display toggles, hover id). Consumed by both the editor and the read-only `/viewer/[id]` route. Must not know about: editor state (`useEditor`, tools, phases, modes), editor-only names baked into presentation modes (`'delete'`, `'paint-ready'`), editor-only state types (material preview, active paint target, floorplan anything).
|
||||||
|
|
||||||
|
**`apps/editor` (and editor-scoped packages) — the editing experience.**
|
||||||
|
Owns: tools, `useEditor`, action menus, panels, the floorplan panel and its helpers, paint mode, selection-manager phase/mode logic, cursor badges, command palette, keyboard shortcuts — anything absent from the read-only viewer route. Injects itself into `<Viewer>` via children and props, never the reverse.
|
||||||
|
|
||||||
|
### Five triggers that mean "this is probably editor"
|
||||||
|
|
||||||
|
1. **Would the read-only `/viewer/[id]` route need this?** If no, it belongs in `apps/editor`.
|
||||||
|
2. **Does the name contain an editor-specific word?** (`Floorplan`, `Paint…`, `Draft…`, `Marquee`, `CursorBadge`, `HoverMode`, `…Tool`, `Moving…`, `Curving…`.) Default to editor and justify loudly if it's anywhere else.
|
||||||
|
3. **Does the type or field reference a tool/mode/phase vocabulary?** (`'delete'`, `'paint-ready'`, `'material-paint'`, `'site'`/`'structure'`/`'furnish'`, `'build'`/`'edit'`.) Belongs in `useEditor`, not `useViewer` or core.
|
||||||
|
4. **Does the helper compute something only a 2D editor view needs?** (Floorplan transforms, measurement offsets, SVG path builders, marquee bounds scoped to floorplan.) Editor. Generic 2D geometry that any view could use (polygon math, rotation, clamping, line thickening) can live in core *as long as its names are generic* — no `Floorplan` prefix.
|
||||||
|
5. **Does a new store field have a setter that no part of the target layer ever calls?** (e.g. `setMaterialPreview` in `useViewer` that only the editor would ever invoke.) That's a layering smell — the state belongs in the caller's layer.
|
||||||
|
|
||||||
|
Write the classification down before writing findings. If core gains "Floorplan" types, or the viewer gains paint-mode vocabulary, or a renderer grows editor awareness — those are the blockers to lead with, not downstream symptoms.
|
||||||
|
|
||||||
|
## 4. Review checklist
|
||||||
|
|
||||||
|
### A. Layer boundaries
|
||||||
|
- `packages/viewer/**` does not import from `apps/editor` or reference `useEditor`, tool state, phase, or mode.
|
||||||
|
- `packages/core/**` does not import Three.js, react-three-fiber, or anything from `packages/viewer` / `apps/editor`.
|
||||||
|
- `packages/core/**` does not introduce types or helpers named after an editor view (`Floorplan*`, `Paint*`, `Draft*`). Generic plan-geometry helpers are fine; view-specific vocabulary is not.
|
||||||
|
- Renderers contain no geometry generation or domain logic — that belongs in a system.
|
||||||
|
- Tools mutate `useScene` (committed state) and `useLiveTransforms` (ephemeral drag state); direct `sceneRegistry` mesh transforms are allowed only under the live-drag exception in `.claude/rules/tools.md`. No business logic, no imports from `packages/viewer`.
|
||||||
|
|
||||||
|
### B. Hook hygiene (`useEditor`, `useScene`, `useViewer`)
|
||||||
|
- Stores hold state + setters only. No business logic, side effects, async work, or derived computations inside the store definition.
|
||||||
|
- Derived values belong in selectors or systems, not in the store body.
|
||||||
|
- No cross-store coupling: a store's action should not call another store's actions inside itself.
|
||||||
|
- New state added to `useViewer` must be presentation-only (selection, camera, level mode, display toggles). Editor-only state (active tool, phase, edit mode, paint preview, floorplan state) goes in `useEditor`.
|
||||||
|
|
||||||
|
### C. Selector performance
|
||||||
|
- Top-level components (pages, layouts, providers, `<Viewer>` siblings) must not subscribe to large or frequently-changing slices — e.g. `useScene(s => s.nodes)`, `useScene(s => s)`. Flag these: they re-render the whole subtree on every mutation.
|
||||||
|
- Selectors that return new object or array references each call (e.g. `s => ({ a: s.a, b: s.b })`, `s => s.items.filter(...)`) without a custom equality function (shallow or custom) are re-render hazards.
|
||||||
|
- Prefer subscribing by ID deep in the tree (one node per renderer) over subscribing to the full collection high up.
|
||||||
|
|
||||||
|
### D. Separation of concerns
|
||||||
|
- Viewer and core stay unaware of editor-specific concepts (tools, phases, active modes, editor UI state, view-specific helpers).
|
||||||
|
- Editor-only overlays and systems are injected as children of `<Viewer>`, not added inside the viewer package.
|
||||||
|
- New node types added correctly: schema → core system (if derived geometry) → viewer renderer → register in `NodeRenderer`.
|
||||||
|
|
||||||
|
## 5. Output format
|
||||||
|
|
||||||
|
Group findings by severity:
|
||||||
|
|
||||||
|
- **Blocker** — violates a rule in `.claude/rules` or breaks a layer boundary. Must be fixed before merge.
|
||||||
|
- **Suggestion** — likely problem, worth discussing. Not a hard block.
|
||||||
|
- **Nit** — minor, optional.
|
||||||
|
|
||||||
|
For each finding, include:
|
||||||
|
|
||||||
|
1. File and line: `path/to/file.ts:42`
|
||||||
|
2. The offending snippet (short — 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
|
||||||
@@ -60,11 +60,15 @@ export function MyTool() {
|
|||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
- **Tools only mutate `useScene`** — they do not call Three.js APIs directly.
|
- **Tools mutate `useScene` for committed changes and `useLiveTransforms` for ephemeral drag state.** A tool's end-of-interaction write (click-to-commit, release-to-commit) goes to `useScene` and is captured in undo history. Per-mouse-move previews go to `useLiveTransforms` so history and subscribers aren't spammed.
|
||||||
|
- **Live-drag exception for direct mesh transforms.** During an active drag a tool may apply a transform offset directly to `sceneRegistry.nodes.get(id).position`/`rotation`/`scale` *when and only when* the same offset is mirrored into `useLiveTransforms` for that node. This exception exists because the 3D renderers don't reconcile `useLiveTransforms` onto `mesh.position` yet; once a `LiveTransformSystem` does that, this exception goes away. Conditions:
|
||||||
|
- The mesh offset must mirror the `useLiveTransforms` entry (same delta on both), so anything reading `useLiveTransforms` sees the same preview as the 3D view.
|
||||||
|
- The offset must be cleared on tool unmount, cancel, *and* commit — both `mesh.position.set(0, 0, 0)` and `useLiveTransforms.clear(id)`.
|
||||||
|
- The tool must not generate or mutate geometry in this path — only transform writes. Geometry generation still belongs in a core system.
|
||||||
- **No business logic in tools** — delegate geometry/constraint rules to core systems.
|
- **No business logic in tools** — delegate geometry/constraint rules to core systems.
|
||||||
- **Preview geometry is local** — transient meshes shown while a tool is active live in the tool component, not in the scene store.
|
- **Preview geometry is local** — transient meshes shown while a tool is active live in the tool component, not in the scene store.
|
||||||
- **Clean up on unmount** — remove any pending/incomplete nodes when the tool unmounts.
|
- **Clean up on unmount** — remove any pending/incomplete nodes *and* any live transforms/mesh offsets when the tool unmounts.
|
||||||
- **Tools must not import from `@pascal-app/viewer`** — use the scene store and core hooks only.
|
- **Tools must not import from `@pascal-app/viewer`** — use the scene store and core hooks only. `sceneRegistry` is exported from `@pascal-app/core` and is the allowed door into the Three.js graph for the narrow purposes above.
|
||||||
- Each tool should handle a single, well-scoped interaction. Split complex tools (e.g. "draw + move") into separate components selected by `useEditor`.
|
- Each tool should handle a single, well-scoped interaction. Split complex tools (e.g. "draw + move") into separate components selected by `useEditor`.
|
||||||
|
|
||||||
## Adding a New Tool
|
## Adding a New Tool
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../.claude/skills/review-architecture/SKILL.md
|
||||||
@@ -42,9 +42,11 @@ export {
|
|||||||
getCatalogMaterialById,
|
getCatalogMaterialById,
|
||||||
getLibraryMaterialIdFromRef,
|
getLibraryMaterialIdFromRef,
|
||||||
getMaterialPresetByRef,
|
getMaterialPresetByRef,
|
||||||
getMaterialsForTarget,
|
getMaterialsForCategory,
|
||||||
LIBRARY_MATERIAL_REF_PREFIX,
|
LIBRARY_MATERIAL_REF_PREFIX,
|
||||||
MATERIAL_CATALOG,
|
MATERIAL_CATALOG,
|
||||||
|
MATERIAL_CATEGORIES,
|
||||||
|
type MaterialCategory,
|
||||||
type MaterialCatalogItem,
|
type MaterialCatalogItem,
|
||||||
toLibraryMaterialRef,
|
toLibraryMaterialRef,
|
||||||
} from './material-library'
|
} from './material-library'
|
||||||
|
|||||||
@@ -1,57 +1,33 @@
|
|||||||
import {
|
import {
|
||||||
type MaterialPresetPayload,
|
type MaterialPresetPayload,
|
||||||
type MaterialTarget,
|
|
||||||
MaterialTarget as MaterialTargetSchema,
|
|
||||||
} from './schema/material'
|
} from './schema/material'
|
||||||
|
|
||||||
export type MaterialCatalogItem = {
|
export type MaterialCatalogItem = {
|
||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
|
category: MaterialCategory
|
||||||
description?: string
|
description?: string
|
||||||
targets: MaterialTarget[]
|
|
||||||
previewThumbnailUrl?: string
|
previewThumbnailUrl?: string
|
||||||
previewColor?: string
|
previewColor?: string
|
||||||
preset: MaterialPresetPayload
|
preset: MaterialPresetPayload
|
||||||
}
|
}
|
||||||
|
|
||||||
const WALL_TARGETS: MaterialTarget[] = [
|
export const MATERIAL_CATEGORIES = [
|
||||||
MaterialTargetSchema.enum.wall,
|
'wood',
|
||||||
]
|
'wallpaper',
|
||||||
|
'parquet',
|
||||||
const SLAB_TARGETS: MaterialTarget[] = [
|
'granite',
|
||||||
MaterialTargetSchema.enum.slab,
|
'marble',
|
||||||
]
|
'other',
|
||||||
|
] as const
|
||||||
const WALL_AND_SLAB_TARGETS: MaterialTarget[] = [
|
export type MaterialCategory = (typeof MATERIAL_CATEGORIES)[number]
|
||||||
MaterialTargetSchema.enum.wall,
|
|
||||||
MaterialTargetSchema.enum.slab,
|
|
||||||
]
|
|
||||||
|
|
||||||
const STAIR_TARGETS: MaterialTarget[] = [
|
|
||||||
MaterialTargetSchema.enum.stair,
|
|
||||||
MaterialTargetSchema.enum['stair-segment'],
|
|
||||||
]
|
|
||||||
|
|
||||||
const STAIR_AND_FENCE_TARGETS: MaterialTarget[] = [
|
|
||||||
...STAIR_TARGETS,
|
|
||||||
MaterialTargetSchema.enum.fence,
|
|
||||||
]
|
|
||||||
|
|
||||||
const ROOF_TARGETS: MaterialTarget[] = [
|
|
||||||
MaterialTargetSchema.enum.roof,
|
|
||||||
MaterialTargetSchema.enum['roof-segment'],
|
|
||||||
]
|
|
||||||
|
|
||||||
const CEILING_TARGETS: MaterialTarget[] = [
|
|
||||||
MaterialTargetSchema.enum.ceiling,
|
|
||||||
]
|
|
||||||
|
|
||||||
export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
||||||
{
|
{
|
||||||
id: 'wall-wood1',
|
id: 'wall-wood1',
|
||||||
label: 'Wood',
|
label: 'Wood',
|
||||||
|
category: 'wood',
|
||||||
description: 'Warm wood finish',
|
description: 'Warm wood finish',
|
||||||
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS, ...ROOF_TARGETS],
|
|
||||||
previewThumbnailUrl: '/material/wood1/wood1_thumbnail.webp',
|
previewThumbnailUrl: '/material/wood1/wood1_thumbnail.webp',
|
||||||
preset: {
|
preset: {
|
||||||
maps: {
|
maps: {
|
||||||
@@ -85,8 +61,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'wall-wood2',
|
id: 'wall-wood2',
|
||||||
label: 'Wood',
|
label: 'Wood',
|
||||||
|
category: 'wood',
|
||||||
description: 'Textured wood finish',
|
description: 'Textured wood finish',
|
||||||
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS, ...ROOF_TARGETS],
|
|
||||||
previewThumbnailUrl: '/material/wood2/wood2_thumbnail.webp',
|
previewThumbnailUrl: '/material/wood2/wood2_thumbnail.webp',
|
||||||
preset: {
|
preset: {
|
||||||
maps: {
|
maps: {
|
||||||
@@ -121,8 +97,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'wall-wood3',
|
id: 'wall-wood3',
|
||||||
label: 'Wood',
|
label: 'Wood',
|
||||||
|
category: 'wood',
|
||||||
description: 'Knotted timber finish',
|
description: 'Knotted timber finish',
|
||||||
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS, ...ROOF_TARGETS],
|
|
||||||
previewThumbnailUrl: '/material/wood3/wood3_thumbnail.webp',
|
previewThumbnailUrl: '/material/wood3/wood3_thumbnail.webp',
|
||||||
preset: {
|
preset: {
|
||||||
maps: {
|
maps: {
|
||||||
@@ -155,8 +131,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'wall-wood4',
|
id: 'wall-wood4',
|
||||||
label: 'Wood',
|
label: 'Wood',
|
||||||
|
category: 'wood',
|
||||||
description: 'Oak stretcher finish',
|
description: 'Oak stretcher finish',
|
||||||
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
|
|
||||||
previewThumbnailUrl: '/material/wood4/wood4_thumbnail.webp',
|
previewThumbnailUrl: '/material/wood4/wood4_thumbnail.webp',
|
||||||
preset: {
|
preset: {
|
||||||
maps: {
|
maps: {
|
||||||
@@ -189,8 +165,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'wall-wood5',
|
id: 'wall-wood5',
|
||||||
label: 'Wood',
|
label: 'Wood',
|
||||||
|
category: 'wood',
|
||||||
description: 'Rich grain wood finish',
|
description: 'Rich grain wood finish',
|
||||||
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
|
|
||||||
previewThumbnailUrl: '/material/wood5/wood5_thumnail.webp',
|
previewThumbnailUrl: '/material/wood5/wood5_thumnail.webp',
|
||||||
preset: {
|
preset: {
|
||||||
maps: {
|
maps: {
|
||||||
@@ -225,8 +201,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'wall-granite1',
|
id: 'wall-granite1',
|
||||||
label: 'Granite',
|
label: 'Granite',
|
||||||
|
category: 'granite',
|
||||||
description: 'Polished granite finish',
|
description: 'Polished granite finish',
|
||||||
targets: SLAB_TARGETS,
|
|
||||||
previewThumbnailUrl: '/material/granite1/granite_thumbnail.webp',
|
previewThumbnailUrl: '/material/granite1/granite_thumbnail.webp',
|
||||||
preset: {
|
preset: {
|
||||||
maps: {
|
maps: {
|
||||||
@@ -259,8 +235,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'wall-marble1',
|
id: 'wall-marble1',
|
||||||
label: 'Marble',
|
label: 'Marble',
|
||||||
|
category: 'marble',
|
||||||
description: 'Smooth marble finish',
|
description: 'Smooth marble finish',
|
||||||
targets: [...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
|
|
||||||
previewThumbnailUrl: '/material/marble1/marble1_thumbnail.webp',
|
previewThumbnailUrl: '/material/marble1/marble1_thumbnail.webp',
|
||||||
preset: {
|
preset: {
|
||||||
maps: {
|
maps: {
|
||||||
@@ -293,8 +269,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'wall-marble2',
|
id: 'wall-marble2',
|
||||||
label: 'Marble',
|
label: 'Marble',
|
||||||
|
category: 'marble',
|
||||||
description: 'Soft marble finish',
|
description: 'Soft marble finish',
|
||||||
targets: [...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
|
|
||||||
previewThumbnailUrl: '/material/marble2/marble2_thumbnail.webp',
|
previewThumbnailUrl: '/material/marble2/marble2_thumbnail.webp',
|
||||||
preset: {
|
preset: {
|
||||||
maps: {
|
maps: {
|
||||||
@@ -327,8 +303,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'wall-parquet1',
|
id: 'wall-parquet1',
|
||||||
label: 'Parquet',
|
label: 'Parquet',
|
||||||
|
category: 'parquet',
|
||||||
description: 'Parquet wood finish',
|
description: 'Parquet wood finish',
|
||||||
targets: SLAB_TARGETS,
|
|
||||||
previewThumbnailUrl: '/material/parquet1/parquet_thumnail.webp',
|
previewThumbnailUrl: '/material/parquet1/parquet_thumnail.webp',
|
||||||
preset: {
|
preset: {
|
||||||
maps: {
|
maps: {
|
||||||
@@ -361,8 +337,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'wall-parquet2',
|
id: 'wall-parquet2',
|
||||||
label: 'Parquet',
|
label: 'Parquet',
|
||||||
|
category: 'parquet',
|
||||||
description: 'Soft parquet finish',
|
description: 'Soft parquet finish',
|
||||||
targets: SLAB_TARGETS,
|
|
||||||
previewThumbnailUrl: '/material/parquet2/parquet2_thumbnail.webp',
|
previewThumbnailUrl: '/material/parquet2/parquet2_thumbnail.webp',
|
||||||
preset: {
|
preset: {
|
||||||
maps: {
|
maps: {
|
||||||
@@ -395,8 +371,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'wall-wallpaper1',
|
id: 'wall-wallpaper1',
|
||||||
label: 'Wallpaper',
|
label: 'Wallpaper',
|
||||||
|
category: 'wallpaper',
|
||||||
description: 'Soft wallpaper finish',
|
description: 'Soft wallpaper finish',
|
||||||
targets: WALL_TARGETS,
|
|
||||||
previewThumbnailUrl: '/material/wallpaper1/wallpaper1_thumbnail.webp',
|
previewThumbnailUrl: '/material/wallpaper1/wallpaper1_thumbnail.webp',
|
||||||
preset: {
|
preset: {
|
||||||
maps: {
|
maps: {
|
||||||
@@ -430,8 +406,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'wall-wallpaper2',
|
id: 'wall-wallpaper2',
|
||||||
label: 'Wallpaper',
|
label: 'Wallpaper',
|
||||||
|
category: 'wallpaper',
|
||||||
description: 'Decorative wallpaper finish',
|
description: 'Decorative wallpaper finish',
|
||||||
targets: WALL_TARGETS,
|
|
||||||
previewThumbnailUrl: '/material/wallpaper2/wallpaper2_thumnail.webp',
|
previewThumbnailUrl: '/material/wallpaper2/wallpaper2_thumnail.webp',
|
||||||
preset: {
|
preset: {
|
||||||
maps: {
|
maps: {
|
||||||
@@ -464,8 +440,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'wall-wallpaper3',
|
id: 'wall-wallpaper3',
|
||||||
label: 'Wallpaper',
|
label: 'Wallpaper',
|
||||||
|
category: 'wallpaper',
|
||||||
description: 'Patterned wallpaper finish',
|
description: 'Patterned wallpaper finish',
|
||||||
targets: WALL_TARGETS,
|
|
||||||
previewThumbnailUrl: '/material/wallpaper3/wallpaper3_thumbnail.webp',
|
previewThumbnailUrl: '/material/wallpaper3/wallpaper3_thumbnail.webp',
|
||||||
preset: {
|
preset: {
|
||||||
maps: {
|
maps: {
|
||||||
@@ -498,14 +474,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'preset-white',
|
id: 'preset-white',
|
||||||
label: 'White',
|
label: 'White',
|
||||||
|
category: 'other',
|
||||||
description: 'Clean painted finish',
|
description: 'Clean painted finish',
|
||||||
targets: [
|
|
||||||
...WALL_TARGETS,
|
|
||||||
...SLAB_TARGETS,
|
|
||||||
...ROOF_TARGETS,
|
|
||||||
...STAIR_AND_FENCE_TARGETS,
|
|
||||||
...CEILING_TARGETS,
|
|
||||||
],
|
|
||||||
previewColor: '#ffffff',
|
previewColor: '#ffffff',
|
||||||
preset: {
|
preset: {
|
||||||
maps: {},
|
maps: {},
|
||||||
@@ -536,8 +506,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'preset-metal',
|
id: 'preset-metal',
|
||||||
label: 'Metal',
|
label: 'Metal',
|
||||||
|
category: 'other',
|
||||||
description: 'Brushed metal finish',
|
description: 'Brushed metal finish',
|
||||||
targets: [...WALL_TARGETS, ...SLAB_TARGETS],
|
|
||||||
previewColor: '#c0c0c0',
|
previewColor: '#c0c0c0',
|
||||||
preset: {
|
preset: {
|
||||||
maps: {},
|
maps: {},
|
||||||
@@ -568,8 +538,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'preset-glass',
|
id: 'preset-glass',
|
||||||
label: 'Glass',
|
label: 'Glass',
|
||||||
|
category: 'other',
|
||||||
description: 'Light glass finish',
|
description: 'Light glass finish',
|
||||||
targets: [...WALL_TARGETS, ...SLAB_TARGETS],
|
|
||||||
previewColor: '#87ceeb',
|
previewColor: '#87ceeb',
|
||||||
preset: {
|
preset: {
|
||||||
maps: {},
|
maps: {},
|
||||||
@@ -599,8 +569,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function getMaterialsForTarget(target: MaterialTarget): MaterialCatalogItem[] {
|
export function getMaterialsForCategory(category: MaterialCategory): MaterialCatalogItem[] {
|
||||||
return MATERIAL_CATALOG.filter((item) => item.targets.includes(target))
|
return MATERIAL_CATALOG.filter((item) => item.category === category)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCatalogMaterialById(id?: string): MaterialCatalogItem | undefined {
|
export function getCatalogMaterialById(id?: string): MaterialCatalogItem | undefined {
|
||||||
|
|||||||
@@ -158,14 +158,16 @@ function migrateStairSurfaceMaterials(node: Record<string, any>) {
|
|||||||
if (node.treadMaterial !== undefined || typeof node.treadMaterialPreset === 'string') {
|
if (node.treadMaterial !== undefined || typeof node.treadMaterialPreset === 'string') {
|
||||||
return {
|
return {
|
||||||
material: node.treadMaterial,
|
material: node.treadMaterial,
|
||||||
materialPreset: typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined,
|
materialPreset:
|
||||||
|
typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.sideMaterial !== undefined || typeof node.sideMaterialPreset === 'string') {
|
if (node.sideMaterial !== undefined || typeof node.sideMaterialPreset === 'string') {
|
||||||
return {
|
return {
|
||||||
material: node.sideMaterial,
|
material: node.sideMaterial,
|
||||||
materialPreset: typeof node.sideMaterialPreset === 'string' ? node.sideMaterialPreset : undefined,
|
materialPreset:
|
||||||
|
typeof node.sideMaterialPreset === 'string' ? node.sideMaterialPreset : undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,16 @@ import {
|
|||||||
spatialGridManager,
|
spatialGridManager,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer'
|
import { type HoverStyles, InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer'
|
||||||
import { memo, type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
|
import {
|
||||||
|
memo,
|
||||||
|
type ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
import { ViewerOverlay } from '../../components/viewer-overlay'
|
import { ViewerOverlay } from '../../components/viewer-overlay'
|
||||||
import { ViewerZoneSystem } from '../../components/viewer-zone-system'
|
import { ViewerZoneSystem } from '../../components/viewer-zone-system'
|
||||||
import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context'
|
import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context'
|
||||||
@@ -22,8 +30,8 @@ import {
|
|||||||
} from '../../lib/scene'
|
} from '../../lib/scene'
|
||||||
import { initSFXBus } from '../../lib/sfx-bus'
|
import { initSFXBus } from '../../lib/sfx-bus'
|
||||||
import useEditor from '../../store/use-editor'
|
import useEditor from '../../store/use-editor'
|
||||||
import { CeilingSystem } from '../systems/ceiling/ceiling-system'
|
|
||||||
import { CeilingSelectionAffordanceSystem } from '../systems/ceiling/ceiling-selection-affordance-system'
|
import { CeilingSelectionAffordanceSystem } from '../systems/ceiling/ceiling-selection-affordance-system'
|
||||||
|
import { CeilingSystem } from '../systems/ceiling/ceiling-system'
|
||||||
import { RoofEditSystem } from '../systems/roof/roof-edit-system'
|
import { RoofEditSystem } from '../systems/roof/roof-edit-system'
|
||||||
import { StairEditSystem } from '../systems/stair/stair-edit-system'
|
import { StairEditSystem } from '../systems/stair/stair-edit-system'
|
||||||
import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system'
|
import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system'
|
||||||
@@ -63,6 +71,21 @@ const CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY = 'editor-camera-controls-hint-
|
|||||||
const DELETE_CURSOR_BADGE_COLOR = '#ef4444'
|
const DELETE_CURSOR_BADGE_COLOR = '#ef4444'
|
||||||
const DELETE_CURSOR_BADGE_OFFSET_X = 14
|
const DELETE_CURSOR_BADGE_OFFSET_X = 14
|
||||||
const DELETE_CURSOR_BADGE_OFFSET_Y = 14
|
const DELETE_CURSOR_BADGE_OFFSET_Y = 14
|
||||||
|
const PAINT_CURSOR_BADGE_COLOR = '#f59e0b'
|
||||||
|
const PAINT_CURSOR_BADGE_DISABLED_COLOR = '#94a3b8'
|
||||||
|
const PAINT_CURSOR_BADGE_OFFSET_X = 14
|
||||||
|
const PAINT_CURSOR_BADGE_OFFSET_Y = 14
|
||||||
|
const EDITOR_HOVER_STYLES: HoverStyles = {
|
||||||
|
default: { visibleColor: 0x00_aaff, hiddenColor: 0xf3_ff47, strength: 5, pulse: true },
|
||||||
|
delete: { visibleColor: 0xef_4444, hiddenColor: 0x99_1b1b, strength: 6, pulse: false },
|
||||||
|
'paint-ready': { visibleColor: 0xf5_9e0b, hiddenColor: 0xfd_e068, strength: 5, pulse: true },
|
||||||
|
'paint-disabled': {
|
||||||
|
visibleColor: 0x94_a3b8,
|
||||||
|
hiddenColor: 0x47_5569,
|
||||||
|
strength: 4,
|
||||||
|
pulse: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wire up module-level singletons (spatial grid, space detection, SFX) for
|
* Wire up module-level singletons (spatial grid, space detection, SFX) for
|
||||||
@@ -501,6 +524,50 @@ function DeleteCursorBadge({ position }: { position: { x: number; y: number } })
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PaintCursorBadge({
|
||||||
|
position,
|
||||||
|
label,
|
||||||
|
disabled,
|
||||||
|
icon,
|
||||||
|
}: {
|
||||||
|
position: { x: number; y: number }
|
||||||
|
label: string
|
||||||
|
disabled: boolean
|
||||||
|
icon: string
|
||||||
|
}) {
|
||||||
|
const accentColor = disabled ? PAINT_CURSOR_BADGE_DISABLED_COLOR : PAINT_CURSOR_BADGE_COLOR
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute z-40"
|
||||||
|
style={{
|
||||||
|
left: position.x + PAINT_CURSOR_BADGE_OFFSET_X,
|
||||||
|
top: position.y + PAINT_CURSOR_BADGE_OFFSET_Y,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 rounded-xl border border-white/5 bg-zinc-900/95 px-3 py-2 shadow-[0_8px_16px_-4px_rgba(0,0,0,0.3),0_4px_8px_-4px_rgba(0,0,0,0.2)]"
|
||||||
|
style={{
|
||||||
|
boxShadow: `0 8px 16px -4px rgba(0,0,0,0.3), 0 4px 8px -4px rgba(0,0,0,0.2), 0 0 18px ${accentColor}22`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
aria-hidden="true"
|
||||||
|
className="drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
|
||||||
|
color={accentColor}
|
||||||
|
height={16}
|
||||||
|
icon={icon}
|
||||||
|
width={16}
|
||||||
|
/>
|
||||||
|
<span className="font-medium text-[11px]" style={{ color: accentColor }}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Viewer scene content: memoized so <Viewer> doesn't re-render on mode/viewMode changes ──
|
// ── Viewer scene content: memoized so <Viewer> doesn't re-render on mode/viewMode changes ──
|
||||||
|
|
||||||
const ViewerSceneContent = memo(function ViewerSceneContent({
|
const ViewerSceneContent = memo(function ViewerSceneContent({
|
||||||
@@ -552,31 +619,165 @@ function DeleteCursorLayer({
|
|||||||
isVersionPreviewMode: boolean
|
isVersionPreviewMode: boolean
|
||||||
}) {
|
}) {
|
||||||
const mode = useEditor((s) => s.mode)
|
const mode = useEditor((s) => s.mode)
|
||||||
const [position, setPosition] = useState<{ x: number; y: number } | null>(null)
|
const badgeRef = useRef<HTMLDivElement>(null)
|
||||||
const active = mode === 'delete' && !isVersionPreviewMode
|
const active = mode === 'delete' && !isVersionPreviewMode
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!active) {
|
if (!active) {
|
||||||
setPosition(null)
|
if (badgeRef.current) {
|
||||||
|
badgeRef.current.style.display = 'none'
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const el = containerRef.current
|
const el = containerRef.current
|
||||||
if (!el) return
|
if (!el) return
|
||||||
|
let frame = 0
|
||||||
|
let nextX = 0
|
||||||
|
let nextY = 0
|
||||||
|
const badge = badgeRef.current
|
||||||
|
|
||||||
|
const flushPosition = () => {
|
||||||
|
frame = 0
|
||||||
|
if (!badge) return
|
||||||
|
badge.style.display = 'block'
|
||||||
|
badge.style.transform = `translate(${nextX + DELETE_CURSOR_BADGE_OFFSET_X}px, ${nextY + DELETE_CURSOR_BADGE_OFFSET_Y}px)`
|
||||||
|
}
|
||||||
|
|
||||||
const onMove = (e: PointerEvent) => {
|
const onMove = (e: PointerEvent) => {
|
||||||
const rect = el.getBoundingClientRect()
|
const rect = el.getBoundingClientRect()
|
||||||
setPosition({ x: e.clientX - rect.left, y: e.clientY - rect.top })
|
nextX = e.clientX - rect.left
|
||||||
|
nextY = e.clientY - rect.top
|
||||||
|
|
||||||
|
if (frame === 0) {
|
||||||
|
frame = window.requestAnimationFrame(flushPosition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const onLeave = () => {
|
||||||
|
if (frame !== 0) {
|
||||||
|
window.cancelAnimationFrame(frame)
|
||||||
|
frame = 0
|
||||||
|
}
|
||||||
|
if (badge) {
|
||||||
|
badge.style.display = 'none'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const onLeave = () => setPosition(null)
|
|
||||||
el.addEventListener('pointermove', onMove)
|
el.addEventListener('pointermove', onMove)
|
||||||
el.addEventListener('pointerleave', onLeave)
|
el.addEventListener('pointerleave', onLeave)
|
||||||
return () => {
|
return () => {
|
||||||
|
if (frame !== 0) {
|
||||||
|
window.cancelAnimationFrame(frame)
|
||||||
|
}
|
||||||
el.removeEventListener('pointermove', onMove)
|
el.removeEventListener('pointermove', onMove)
|
||||||
el.removeEventListener('pointerleave', onLeave)
|
el.removeEventListener('pointerleave', onLeave)
|
||||||
}
|
}
|
||||||
}, [active, containerRef])
|
}, [active, containerRef])
|
||||||
|
|
||||||
if (!(active && position)) return null
|
if (!active) return null
|
||||||
return <DeleteCursorBadge position={position} />
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none"
|
||||||
|
ref={badgeRef}
|
||||||
|
style={{ display: 'none', position: 'absolute', left: 0, top: 0 }}
|
||||||
|
>
|
||||||
|
<DeleteCursorBadge position={{ x: 0, y: 0 }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaintCursorLayer({
|
||||||
|
containerRef,
|
||||||
|
isVersionPreviewMode,
|
||||||
|
}: {
|
||||||
|
containerRef: React.RefObject<HTMLDivElement | null>
|
||||||
|
isVersionPreviewMode: boolean
|
||||||
|
}) {
|
||||||
|
const mode = useEditor((s) => s.mode)
|
||||||
|
const activePaintMaterial = useEditor((s) => s.activePaintMaterial)
|
||||||
|
const activePaintTarget = useEditor((s) => s.activePaintTarget)
|
||||||
|
const badgeRef = useRef<HTMLDivElement>(null)
|
||||||
|
const active = mode === 'material-paint' && !isVersionPreviewMode
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) {
|
||||||
|
if (badgeRef.current) {
|
||||||
|
badgeRef.current.style.display = 'none'
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const el = containerRef.current
|
||||||
|
if (!el) return
|
||||||
|
let frame = 0
|
||||||
|
let nextX = 0
|
||||||
|
let nextY = 0
|
||||||
|
const badge = badgeRef.current
|
||||||
|
|
||||||
|
const flushPosition = () => {
|
||||||
|
frame = 0
|
||||||
|
if (!badge) return
|
||||||
|
badge.style.display = 'block'
|
||||||
|
badge.style.transform = `translate(${nextX + PAINT_CURSOR_BADGE_OFFSET_X}px, ${nextY + PAINT_CURSOR_BADGE_OFFSET_Y}px)`
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMove = (e: PointerEvent) => {
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
nextX = e.clientX - rect.left
|
||||||
|
nextY = e.clientY - rect.top
|
||||||
|
|
||||||
|
if (frame === 0) {
|
||||||
|
frame = window.requestAnimationFrame(flushPosition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const onLeave = () => {
|
||||||
|
if (frame !== 0) {
|
||||||
|
window.cancelAnimationFrame(frame)
|
||||||
|
frame = 0
|
||||||
|
}
|
||||||
|
if (badge) {
|
||||||
|
badge.style.display = 'none'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
el.addEventListener('pointermove', onMove)
|
||||||
|
el.addEventListener('pointerleave', onLeave)
|
||||||
|
return () => {
|
||||||
|
if (frame !== 0) {
|
||||||
|
window.cancelAnimationFrame(frame)
|
||||||
|
}
|
||||||
|
el.removeEventListener('pointermove', onMove)
|
||||||
|
el.removeEventListener('pointerleave', onLeave)
|
||||||
|
}
|
||||||
|
}, [active, containerRef])
|
||||||
|
|
||||||
|
const hasMaterial = Boolean(
|
||||||
|
activePaintMaterial &&
|
||||||
|
(activePaintMaterial.material !== undefined ||
|
||||||
|
activePaintMaterial.materialPreset !== undefined),
|
||||||
|
)
|
||||||
|
const label = !hasMaterial ? 'Choose material' : `Paint ${activePaintTarget}`
|
||||||
|
const icon = 'mdi:format-color-fill'
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!active && badgeRef.current) {
|
||||||
|
badgeRef.current.style.display = 'none'
|
||||||
|
}
|
||||||
|
}, [active])
|
||||||
|
|
||||||
|
if (!active) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none"
|
||||||
|
ref={badgeRef}
|
||||||
|
style={{ display: 'none', position: 'absolute', left: 0, top: 0 }}
|
||||||
|
>
|
||||||
|
<PaintCursorBadge
|
||||||
|
disabled={!hasMaterial}
|
||||||
|
icon={icon}
|
||||||
|
label={label}
|
||||||
|
position={{ x: 0, y: 0 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Viewer canvas: memoized, subscribes to viewMode/floorplanPaneRatio internally ──
|
// ── Viewer canvas: memoized, subscribes to viewMode/floorplanPaneRatio internally ──
|
||||||
@@ -684,6 +885,10 @@ const ViewerCanvas = memo(function ViewerCanvas({
|
|||||||
containerRef={viewer3dRef}
|
containerRef={viewer3dRef}
|
||||||
isVersionPreviewMode={isVersionPreviewMode}
|
isVersionPreviewMode={isVersionPreviewMode}
|
||||||
/>
|
/>
|
||||||
|
<PaintCursorLayer
|
||||||
|
containerRef={viewer3dRef}
|
||||||
|
isVersionPreviewMode={isVersionPreviewMode}
|
||||||
|
/>
|
||||||
{!showLoader && isCameraControlsHintVisible && !isFirstPersonMode ? (
|
{!showLoader && isCameraControlsHintVisible && !isFirstPersonMode ? (
|
||||||
<ViewerCanvasControlsHint
|
<ViewerCanvasControlsHint
|
||||||
isPreviewMode={isPreviewMode}
|
isPreviewMode={isPreviewMode}
|
||||||
@@ -691,7 +896,10 @@ const ViewerCanvas = memo(function ViewerCanvas({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<SelectionPersistenceManager enabled={hasLoadedInitialScene && !showLoader} />
|
<SelectionPersistenceManager enabled={hasLoadedInitialScene && !showLoader} />
|
||||||
<Viewer selectionManager={isFirstPersonMode ? 'default' : 'custom'}>
|
<Viewer
|
||||||
|
hoverStyles={EDITOR_HOVER_STYLES}
|
||||||
|
selectionManager={isFirstPersonMode ? 'default' : 'custom'}
|
||||||
|
>
|
||||||
<ViewerSceneContent
|
<ViewerSceneContent
|
||||||
isFirstPersonMode={isFirstPersonMode}
|
isFirstPersonMode={isFirstPersonMode}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
@@ -823,7 +1031,7 @@ export default function Editor({
|
|||||||
const showLoader = isLoading || isSceneLoading
|
const showLoader = isLoading || isSceneLoading
|
||||||
|
|
||||||
const previewViewerContent = (
|
const previewViewerContent = (
|
||||||
<Viewer selectionManager="default">
|
<Viewer hoverStyles={EDITOR_HOVER_STYLES} selectionManager="default">
|
||||||
<ExportManager />
|
<ExportManager />
|
||||||
<ViewerZoneSystem />
|
<ViewerZoneSystem />
|
||||||
<CeilingSystem />
|
<CeilingSystem />
|
||||||
|
|||||||
@@ -2,27 +2,56 @@ import {
|
|||||||
type AnyNode,
|
type AnyNode,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
type BuildingNode,
|
type BuildingNode,
|
||||||
|
type CeilingNode,
|
||||||
emitter,
|
emitter,
|
||||||
|
type FenceNode,
|
||||||
|
getMaterialPresetByRef,
|
||||||
type ItemNode,
|
type ItemNode,
|
||||||
type NodeEvent,
|
type NodeEvent,
|
||||||
type RoofEvent,
|
type RoofEvent,
|
||||||
|
type RoofNode,
|
||||||
type RoofSegmentEvent,
|
type RoofSegmentEvent,
|
||||||
resolveLevelId,
|
resolveLevelId,
|
||||||
sceneRegistry,
|
resolveMaterial,
|
||||||
|
type SlabNode,
|
||||||
type StairEvent,
|
type StairEvent,
|
||||||
type StairNode,
|
type StairNode,
|
||||||
type StairSurfaceMaterialRole,
|
|
||||||
type StairSegmentEvent,
|
type StairSegmentEvent,
|
||||||
|
type StairSurfaceMaterialRole,
|
||||||
|
sceneRegistry,
|
||||||
useScene,
|
useScene,
|
||||||
type WallEvent,
|
type WallEvent,
|
||||||
|
type WallNode,
|
||||||
type WallSurfaceSide,
|
type WallSurfaceSide,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
|
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import {
|
||||||
|
applyMaterialPresetToMaterials,
|
||||||
|
createMaterial,
|
||||||
|
createMaterialFromPresetRef,
|
||||||
|
getRoofMaterialArray,
|
||||||
|
getStairBodyMaterials,
|
||||||
|
getStairRailingMaterial,
|
||||||
|
getVisibleWallMaterials,
|
||||||
|
useViewer,
|
||||||
|
} from '@pascal-app/viewer'
|
||||||
import { useCallback, useEffect, useRef } from 'react'
|
import { useCallback, useEffect, useRef } from 'react'
|
||||||
import { Color, type BufferGeometry, type Material, type Mesh, type Object3D } from 'three'
|
import { type BufferGeometry, Color, type Material, type Mesh, type Object3D } from 'three'
|
||||||
|
import {
|
||||||
|
type ActivePaintMaterial,
|
||||||
|
buildRoofSurfaceMaterialPatch,
|
||||||
|
buildSingleSurfaceMaterialPatch,
|
||||||
|
buildStairSurfaceMaterialPatch,
|
||||||
|
buildWallSurfaceMaterialPatch,
|
||||||
|
hasActivePaintMaterial,
|
||||||
|
resolveActivePaintMaterialFromSelection,
|
||||||
|
} from '../../lib/material-paint'
|
||||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||||
import useEditor, { type MaterialTargetRole, type Phase, type StructureLayer } from './../../store/use-editor'
|
import useEditor, {
|
||||||
|
type MaterialTargetRole,
|
||||||
|
type Phase,
|
||||||
|
type StructureLayer,
|
||||||
|
} from './../../store/use-editor'
|
||||||
import { boxSelectHandled } from '../tools/select/box-select-tool'
|
import { boxSelectHandled } from '../tools/select/box-select-tool'
|
||||||
|
|
||||||
const isNodeInCurrentLevel = (node: AnyNode): boolean => {
|
const isNodeInCurrentLevel = (node: AnyNode): boolean => {
|
||||||
@@ -52,6 +81,16 @@ type ModifierKeys = {
|
|||||||
ctrl: boolean
|
ctrl: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PaintPreviewCleanup = () => void
|
||||||
|
|
||||||
|
type PaintInteraction = {
|
||||||
|
key: string
|
||||||
|
apply: (() => void) | null
|
||||||
|
hoverMode: HoverHighlightMode
|
||||||
|
hoveredId: AnyNodeId
|
||||||
|
preview: (() => PaintPreviewCleanup | null) | null
|
||||||
|
}
|
||||||
|
|
||||||
interface SelectionStrategy {
|
interface SelectionStrategy {
|
||||||
types: SelectableNodeType[]
|
types: SelectableNodeType[]
|
||||||
handleSelect: (node: AnyNode, nativeEvent?: any, modifierKeys?: ModifierKeys) => void
|
handleSelect: (node: AnyNode, nativeEvent?: any, modifierKeys?: ModifierKeys) => void
|
||||||
@@ -175,10 +214,197 @@ function getIntersectionMaterialIndex(
|
|||||||
return group?.materialIndex
|
return group?.materialIndex
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSelectedMaterialTargetForNode(
|
function getRegisteredNodeObject(nodeId: string): Object3D | null {
|
||||||
node: AnyNode,
|
return sceneRegistry.nodes.get(nodeId) ?? null
|
||||||
role: MaterialTargetRole | null,
|
}
|
||||||
) {
|
|
||||||
|
function getRegisteredMesh(nodeId: string): Mesh | null {
|
||||||
|
const object = getRegisteredNodeObject(nodeId)
|
||||||
|
return object && (object as Mesh).isMesh ? (object as Mesh) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewMeshMaterial(mesh: Mesh, material: Material | Material[]): PaintPreviewCleanup {
|
||||||
|
const previousMaterial = mesh.material
|
||||||
|
mesh.material = material
|
||||||
|
return () => {
|
||||||
|
mesh.material = previousMaterial
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewCursor(cursor: string): PaintPreviewCleanup {
|
||||||
|
const previousCursor = document.body.style.cursor
|
||||||
|
document.body.style.cursor = cursor
|
||||||
|
return () => {
|
||||||
|
document.body.style.cursor = previousCursor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSingleSurfacePreviewMaterial(material: ActivePaintMaterial): Material | null {
|
||||||
|
if (material.materialPreset) {
|
||||||
|
return createMaterialFromPresetRef(material.materialPreset)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (material.material) {
|
||||||
|
return createMaterial(material.material)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyWallPaintPreview(
|
||||||
|
node: WallNode,
|
||||||
|
role: WallSurfaceSide,
|
||||||
|
material: ActivePaintMaterial,
|
||||||
|
): PaintPreviewCleanup | null {
|
||||||
|
const mesh = getRegisteredMesh(node.id)
|
||||||
|
if (!mesh) return null
|
||||||
|
|
||||||
|
const previewNode = {
|
||||||
|
...node,
|
||||||
|
...buildWallSurfaceMaterialPatch(node, role, material.material, material.materialPreset),
|
||||||
|
}
|
||||||
|
|
||||||
|
return previewMeshMaterial(mesh, getVisibleWallMaterials(previewNode))
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRoofPaintPreview(
|
||||||
|
node: RoofNode,
|
||||||
|
role: 'top' | 'edge' | 'wall',
|
||||||
|
material: ActivePaintMaterial,
|
||||||
|
): PaintPreviewCleanup | null {
|
||||||
|
const root = getRegisteredNodeObject(node.id)
|
||||||
|
const mesh = root?.getObjectByName('merged-roof') as Mesh | undefined
|
||||||
|
if (!mesh) return null
|
||||||
|
|
||||||
|
const previewNode = {
|
||||||
|
...node,
|
||||||
|
...buildRoofSurfaceMaterialPatch(node, role, material.material, material.materialPreset),
|
||||||
|
}
|
||||||
|
const previewMaterial = getRoofMaterialArray(previewNode)
|
||||||
|
if (!previewMaterial) return null
|
||||||
|
|
||||||
|
return previewMeshMaterial(mesh, previewMaterial)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyStairPaintPreview(
|
||||||
|
node: StairNode,
|
||||||
|
role: StairSurfaceMaterialRole,
|
||||||
|
material: ActivePaintMaterial,
|
||||||
|
): PaintPreviewCleanup | null {
|
||||||
|
const root = getRegisteredNodeObject(node.id)
|
||||||
|
if (!root) return null
|
||||||
|
|
||||||
|
const previewNode = {
|
||||||
|
...node,
|
||||||
|
...buildStairSurfaceMaterialPatch(node, role, material.material, material.materialPreset),
|
||||||
|
}
|
||||||
|
const bodyMaterials = getStairBodyMaterials(previewNode)
|
||||||
|
const railingMaterial = getStairRailingMaterial(previewNode)
|
||||||
|
const restores: PaintPreviewCleanup[] = []
|
||||||
|
|
||||||
|
root.traverse((object) => {
|
||||||
|
if (!(object as Mesh).isMesh) return
|
||||||
|
const mesh = object as Mesh
|
||||||
|
if (mesh.name.startsWith('stair-railing')) {
|
||||||
|
restores.push(previewMeshMaterial(mesh, railingMaterial))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (Array.isArray(mesh.material) && mesh.material.length === 2) {
|
||||||
|
restores.push(previewMeshMaterial(mesh, bodyMaterials))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (mesh.name === 'merged-stair') {
|
||||||
|
restores.push(previewMeshMaterial(mesh, bodyMaterials))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (mesh.name.startsWith('stair-side')) {
|
||||||
|
restores.push(previewMeshMaterial(mesh, bodyMaterials[1]))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (restores.length === 0) return null
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
for (let index = restores.length - 1; index >= 0; index -= 1) {
|
||||||
|
restores[index]?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySingleSurfacePaintPreview(
|
||||||
|
node: FenceNode | SlabNode | CeilingNode,
|
||||||
|
material: ActivePaintMaterial,
|
||||||
|
): PaintPreviewCleanup | null {
|
||||||
|
if (node.type === 'ceiling') {
|
||||||
|
const root = getRegisteredMesh(node.id)
|
||||||
|
const overlay = root?.getObjectByName('ceiling-grid') as Mesh | undefined
|
||||||
|
if (!root || !overlay) return null
|
||||||
|
|
||||||
|
const previewColor =
|
||||||
|
getMaterialPresetByRef(material.materialPreset)?.mapProperties.color ??
|
||||||
|
resolveMaterial(material.material).color ??
|
||||||
|
'#999999'
|
||||||
|
|
||||||
|
const previousRootMaterial = root.material
|
||||||
|
const previousOverlayMaterial = overlay.material
|
||||||
|
const rootPreviewMaterial = Array.isArray(previousRootMaterial)
|
||||||
|
? previousRootMaterial.map((entry) => entry.clone())
|
||||||
|
: previousRootMaterial.clone()
|
||||||
|
const overlayPreviewMaterial = Array.isArray(previousOverlayMaterial)
|
||||||
|
? previousOverlayMaterial.map((entry) => entry.clone())
|
||||||
|
: previousOverlayMaterial.clone()
|
||||||
|
|
||||||
|
const applyColor = (input: Material | Material[]) => {
|
||||||
|
const materials = Array.isArray(input) ? input : [input]
|
||||||
|
for (const entry of materials) {
|
||||||
|
const materialWithColor = entry as Material & { color?: Color; needsUpdate?: boolean }
|
||||||
|
if (materialWithColor.color instanceof Color) {
|
||||||
|
materialWithColor.color = new Color(previewColor)
|
||||||
|
}
|
||||||
|
materialWithColor.needsUpdate = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applyColor(rootPreviewMaterial)
|
||||||
|
applyColor(overlayPreviewMaterial)
|
||||||
|
root.material = rootPreviewMaterial
|
||||||
|
overlay.material = overlayPreviewMaterial
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
root.material = previousRootMaterial
|
||||||
|
overlay.material = previousOverlayMaterial
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mesh = getRegisteredMesh(node.id)
|
||||||
|
if (!mesh) return null
|
||||||
|
|
||||||
|
const previewMaterial = getSingleSurfacePreviewMaterial(material)
|
||||||
|
if (!previewMaterial) return null
|
||||||
|
|
||||||
|
if (node.type === 'slab') {
|
||||||
|
const slabMaterial = previewMaterial.clone()
|
||||||
|
applyMaterialPresetToMaterials(slabMaterial, getMaterialPresetByRef(material.materialPreset))
|
||||||
|
const previewMeshMaterialInput = slabMaterial as Material & {
|
||||||
|
alphaMap?: unknown
|
||||||
|
depthWrite?: boolean
|
||||||
|
needsUpdate?: boolean
|
||||||
|
opacity?: number
|
||||||
|
side?: number
|
||||||
|
transparent?: boolean
|
||||||
|
}
|
||||||
|
previewMeshMaterialInput.transparent = false
|
||||||
|
previewMeshMaterialInput.opacity = 1
|
||||||
|
previewMeshMaterialInput.alphaMap = null
|
||||||
|
previewMeshMaterialInput.depthWrite = true
|
||||||
|
previewMeshMaterialInput.needsUpdate = true
|
||||||
|
return previewMeshMaterial(mesh, slabMaterial)
|
||||||
|
}
|
||||||
|
|
||||||
|
return previewMeshMaterial(mesh, previewMaterial)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSelectedMaterialTargetForNode(node: AnyNode, role: MaterialTargetRole | null) {
|
||||||
if (!role) {
|
if (!role) {
|
||||||
const currentTarget = useEditor.getState().selectedMaterialTarget
|
const currentTarget = useEditor.getState().selectedMaterialTarget
|
||||||
if (currentTarget?.nodeId !== node.id) {
|
if (currentTarget?.nodeId !== node.id) {
|
||||||
@@ -209,6 +435,7 @@ const HIGHLIGHT_PROFILES = {
|
|||||||
} as const
|
} as const
|
||||||
|
|
||||||
type HighlightKind = keyof typeof HIGHLIGHT_PROFILES
|
type HighlightKind = keyof typeof HIGHLIGHT_PROFILES
|
||||||
|
type HoverHighlightMode = 'default' | 'delete' | 'paint-ready' | 'paint-disabled'
|
||||||
|
|
||||||
type HighlightableMaterial = Material & {
|
type HighlightableMaterial = Material & {
|
||||||
color?: Color
|
color?: Color
|
||||||
@@ -475,13 +702,292 @@ export const SelectionManager = () => {
|
|||||||
const curvingFence = useEditor((s) => s.curvingFence)
|
const curvingFence = useEditor((s) => s.curvingFence)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setHoverHighlightMode(mode === 'delete' ? 'delete' : 'default')
|
const nextHoverMode: HoverHighlightMode = mode === 'delete' ? 'delete' : 'default'
|
||||||
|
setHoverHighlightMode(nextHoverMode)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
setHoverHighlightMode('default')
|
setHoverHighlightMode('default')
|
||||||
}
|
}
|
||||||
}, [mode, setHoverHighlightMode])
|
}, [mode, setHoverHighlightMode])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== 'material-paint') return
|
||||||
|
if (movingNode || curvingWall) return
|
||||||
|
|
||||||
|
let activePreview: { key: string; restore: PaintPreviewCleanup } | null = null
|
||||||
|
|
||||||
|
const clearActivePreview = () => {
|
||||||
|
activePreview?.restore()
|
||||||
|
activePreview = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveActivePaintMaterial = () =>
|
||||||
|
useEditor.getState().activePaintMaterial ??
|
||||||
|
resolveActivePaintMaterialFromSelection({
|
||||||
|
nodes: useScene.getState().nodes,
|
||||||
|
selectedId:
|
||||||
|
useViewer.getState().selection.selectedIds.length === 1
|
||||||
|
? (useViewer.getState().selection.selectedIds[0] ?? null)
|
||||||
|
: null,
|
||||||
|
selectedMaterialTarget: useEditor.getState().selectedMaterialTarget,
|
||||||
|
})
|
||||||
|
|
||||||
|
const getPaintInteraction = (event: NodeEvent): PaintInteraction | null => {
|
||||||
|
const activePaintMaterial = resolveActivePaintMaterial()
|
||||||
|
const node = event.node
|
||||||
|
|
||||||
|
if (!isNodeInCurrentLevel(node)) return null
|
||||||
|
|
||||||
|
if (node.type === 'wall') {
|
||||||
|
const role = resolveWallMaterialTarget(event as WallEvent)
|
||||||
|
const compatible = role !== null && hasActivePaintMaterial(activePaintMaterial)
|
||||||
|
return {
|
||||||
|
key: `wall:${node.id}:${role ?? 'unsupported'}`,
|
||||||
|
hoveredId: node.id as AnyNodeId,
|
||||||
|
hoverMode:
|
||||||
|
compatible && hasActivePaintMaterial(activePaintMaterial) && role
|
||||||
|
? 'paint-ready'
|
||||||
|
: 'paint-disabled',
|
||||||
|
apply:
|
||||||
|
compatible && hasActivePaintMaterial(activePaintMaterial)
|
||||||
|
? () => {
|
||||||
|
useScene
|
||||||
|
.getState()
|
||||||
|
.updateNode(
|
||||||
|
node.id as AnyNodeId,
|
||||||
|
buildWallSurfaceMaterialPatch(
|
||||||
|
node as WallNode,
|
||||||
|
role!,
|
||||||
|
activePaintMaterial.material,
|
||||||
|
activePaintMaterial.materialPreset,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
preview:
|
||||||
|
compatible && hasActivePaintMaterial(activePaintMaterial) && role
|
||||||
|
? () => applyWallPaintPreview(node as WallNode, role, activePaintMaterial)
|
||||||
|
: () => previewCursor('not-allowed'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'roof' || node.type === 'roof-segment') {
|
||||||
|
const roofNode =
|
||||||
|
node.type === 'roof'
|
||||||
|
? node
|
||||||
|
: node.parentId
|
||||||
|
? useScene.getState().nodes[node.parentId as AnyNodeId]
|
||||||
|
: null
|
||||||
|
if (!roofNode || roofNode.type !== 'roof') return null
|
||||||
|
|
||||||
|
const role = resolveRoofMaterialTarget(event as RoofEvent | RoofSegmentEvent)
|
||||||
|
const compatible = role !== null && hasActivePaintMaterial(activePaintMaterial)
|
||||||
|
return {
|
||||||
|
key: `roof:${roofNode.id}:${role ?? 'unsupported'}`,
|
||||||
|
hoveredId: roofNode.id as AnyNodeId,
|
||||||
|
hoverMode:
|
||||||
|
compatible && hasActivePaintMaterial(activePaintMaterial) && role
|
||||||
|
? 'paint-ready'
|
||||||
|
: 'paint-disabled',
|
||||||
|
apply:
|
||||||
|
compatible && hasActivePaintMaterial(activePaintMaterial)
|
||||||
|
? () => {
|
||||||
|
useScene
|
||||||
|
.getState()
|
||||||
|
.updateNode(
|
||||||
|
roofNode.id as AnyNodeId,
|
||||||
|
buildRoofSurfaceMaterialPatch(
|
||||||
|
roofNode as RoofNode,
|
||||||
|
role!,
|
||||||
|
activePaintMaterial.material,
|
||||||
|
activePaintMaterial.materialPreset,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
preview:
|
||||||
|
compatible && hasActivePaintMaterial(activePaintMaterial) && role
|
||||||
|
? () => applyRoofPaintPreview(roofNode as RoofNode, role, activePaintMaterial)
|
||||||
|
: () => previewCursor('not-allowed'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'stair' || node.type === 'stair-segment') {
|
||||||
|
const stairNode =
|
||||||
|
node.type === 'stair'
|
||||||
|
? node
|
||||||
|
: node.parentId
|
||||||
|
? useScene.getState().nodes[node.parentId as AnyNodeId]
|
||||||
|
: null
|
||||||
|
if (!stairNode || stairNode.type !== 'stair') return null
|
||||||
|
|
||||||
|
const role = resolveStairMaterialTarget(event as StairEvent | StairSegmentEvent)
|
||||||
|
const compatible = role !== null && hasActivePaintMaterial(activePaintMaterial)
|
||||||
|
return {
|
||||||
|
key: `stair:${stairNode.id}:${role ?? 'unsupported'}`,
|
||||||
|
hoveredId: stairNode.id as AnyNodeId,
|
||||||
|
hoverMode:
|
||||||
|
compatible && hasActivePaintMaterial(activePaintMaterial) && role
|
||||||
|
? 'paint-ready'
|
||||||
|
: 'paint-disabled',
|
||||||
|
apply:
|
||||||
|
compatible && hasActivePaintMaterial(activePaintMaterial)
|
||||||
|
? () => {
|
||||||
|
useScene
|
||||||
|
.getState()
|
||||||
|
.updateNode(
|
||||||
|
stairNode.id as AnyNodeId,
|
||||||
|
buildStairSurfaceMaterialPatch(
|
||||||
|
stairNode as StairNode,
|
||||||
|
role!,
|
||||||
|
activePaintMaterial.material,
|
||||||
|
activePaintMaterial.materialPreset,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
preview:
|
||||||
|
compatible && hasActivePaintMaterial(activePaintMaterial) && role
|
||||||
|
? () => applyStairPaintPreview(stairNode as StairNode, role, activePaintMaterial)
|
||||||
|
: () => previewCursor('not-allowed'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'fence' || node.type === 'slab' || node.type === 'ceiling') {
|
||||||
|
const compatible = hasActivePaintMaterial(activePaintMaterial)
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: `${node.type}:${node.id}:surface`,
|
||||||
|
hoveredId: node.id as AnyNodeId,
|
||||||
|
hoverMode: compatible ? 'paint-ready' : 'paint-disabled',
|
||||||
|
apply: compatible
|
||||||
|
? () => {
|
||||||
|
useScene
|
||||||
|
.getState()
|
||||||
|
.updateNode(
|
||||||
|
node.id as AnyNodeId,
|
||||||
|
buildSingleSurfaceMaterialPatch<FenceNode | SlabNode | CeilingNode>(
|
||||||
|
activePaintMaterial.material,
|
||||||
|
activePaintMaterial.materialPreset,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
preview: compatible
|
||||||
|
? () =>
|
||||||
|
applySingleSurfacePaintPreview(
|
||||||
|
node as FenceNode | SlabNode | CeilingNode,
|
||||||
|
activePaintMaterial,
|
||||||
|
)
|
||||||
|
: () => previewCursor('not-allowed'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const disabledNodeTypes = ['item', 'window', 'door', 'zone']
|
||||||
|
if (disabledNodeTypes.includes(node.type)) {
|
||||||
|
return {
|
||||||
|
key: `${node.type}:${node.id}:unsupported`,
|
||||||
|
hoveredId: node.id as AnyNodeId,
|
||||||
|
hoverMode: 'paint-disabled',
|
||||||
|
apply: null,
|
||||||
|
preview: () => previewCursor('not-allowed'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const onEnter = (event: NodeEvent) => {
|
||||||
|
if (boxSelectHandled) return
|
||||||
|
|
||||||
|
const interaction = getPaintInteraction(event)
|
||||||
|
if (!interaction) return
|
||||||
|
|
||||||
|
event.stopPropagation()
|
||||||
|
|
||||||
|
if (activePreview?.key === interaction.key) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clearActivePreview()
|
||||||
|
useViewer.setState({ hoveredId: interaction.hoveredId })
|
||||||
|
setHoverHighlightMode(interaction.hoverMode)
|
||||||
|
|
||||||
|
const restore = interaction.preview?.()
|
||||||
|
if (restore) {
|
||||||
|
activePreview = { key: interaction.key, restore }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onLeave = (event: NodeEvent) => {
|
||||||
|
const interaction = getPaintInteraction(event)
|
||||||
|
if (!interaction) return
|
||||||
|
|
||||||
|
if (activePreview?.key !== interaction.key) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clearActivePreview()
|
||||||
|
if (useViewer.getState().hoveredId === interaction.hoveredId) {
|
||||||
|
useViewer.setState({ hoveredId: null })
|
||||||
|
}
|
||||||
|
setHoverHighlightMode('default')
|
||||||
|
}
|
||||||
|
|
||||||
|
const onClick = (event: NodeEvent) => {
|
||||||
|
if (boxSelectHandled) return
|
||||||
|
|
||||||
|
const interaction = getPaintInteraction(event)
|
||||||
|
if (!interaction) return
|
||||||
|
|
||||||
|
event.stopPropagation()
|
||||||
|
|
||||||
|
if (!interaction.apply) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
interaction.apply()
|
||||||
|
if (activePreview?.key === interaction.key) {
|
||||||
|
activePreview = null
|
||||||
|
} else {
|
||||||
|
clearActivePreview()
|
||||||
|
}
|
||||||
|
setHoverHighlightMode(interaction.hoverMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
const allTypes = [
|
||||||
|
'wall',
|
||||||
|
'fence',
|
||||||
|
'item',
|
||||||
|
'slab',
|
||||||
|
'ceiling',
|
||||||
|
'roof',
|
||||||
|
'roof-segment',
|
||||||
|
'stair',
|
||||||
|
'stair-segment',
|
||||||
|
'window',
|
||||||
|
'door',
|
||||||
|
'zone',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
for (const type of allTypes) {
|
||||||
|
emitter.on(`${type}:enter` as any, onEnter as any)
|
||||||
|
emitter.on(`${type}:leave` as any, onLeave as any)
|
||||||
|
emitter.on(`${type}:click` as any, onClick as any)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
for (const type of allTypes) {
|
||||||
|
emitter.off(`${type}:enter` as any, onEnter as any)
|
||||||
|
emitter.off(`${type}:leave` as any, onLeave as any)
|
||||||
|
emitter.off(`${type}:click` as any, onClick as any)
|
||||||
|
}
|
||||||
|
clearActivePreview()
|
||||||
|
useViewer.setState({ hoveredId: null })
|
||||||
|
setHoverHighlightMode('default')
|
||||||
|
}
|
||||||
|
}, [curvingWall, mode, movingNode, setHoverHighlightMode])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Meta') modifierKeysRef.current.meta = true
|
if (event.key === 'Meta') modifierKeysRef.current.meta = true
|
||||||
@@ -597,6 +1103,14 @@ export const SelectionManager = () => {
|
|||||||
nextMaterialTargetHandled = true
|
nextMaterialTargetHandled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(node.type === 'fence' || node.type === 'slab' || node.type === 'ceiling') &&
|
||||||
|
nodeToSelect.type === node.type
|
||||||
|
) {
|
||||||
|
setSelectedMaterialTargetForNode(nodeToSelect, 'surface')
|
||||||
|
nextMaterialTargetHandled = true
|
||||||
|
}
|
||||||
|
|
||||||
if (!nextMaterialTargetHandled && useEditor.getState().selectedMaterialTarget) {
|
if (!nextMaterialTargetHandled && useEditor.getState().selectedMaterialTarget) {
|
||||||
useEditor.getState().setSelectedMaterialTarget(null)
|
useEditor.getState().setSelectedMaterialTarget(null)
|
||||||
}
|
}
|
||||||
@@ -912,7 +1426,12 @@ const SelectionStateSync = () => {
|
|||||||
const selectedNode = useScene.getState().nodes[singleSelectedId as AnyNodeId]
|
const selectedNode = useScene.getState().nodes[singleSelectedId as AnyNodeId]
|
||||||
if (
|
if (
|
||||||
!selectedNode ||
|
!selectedNode ||
|
||||||
(selectedNode.type !== 'wall' && selectedNode.type !== 'stair' && selectedNode.type !== 'roof')
|
(selectedNode.type !== 'wall' &&
|
||||||
|
selectedNode.type !== 'fence' &&
|
||||||
|
selectedNode.type !== 'slab' &&
|
||||||
|
selectedNode.type !== 'ceiling' &&
|
||||||
|
selectedNode.type !== 'stair' &&
|
||||||
|
selectedNode.type !== 'roof')
|
||||||
) {
|
) {
|
||||||
setSelectedMaterialTarget(null)
|
setSelectedMaterialTarget(null)
|
||||||
return
|
return
|
||||||
@@ -1022,7 +1541,8 @@ const SelectionMaterialSync = () => {
|
|||||||
}, [hoverHighlightMode, hoveredId, previewSelectedIds, selectedIds, syncSelectionMaterials])
|
}, [hoverHighlightMode, hoveredId, previewSelectedIds, selectedIds, syncSelectionMaterials])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return useScene.subscribe(() => {
|
return useScene.subscribe((state, prevState) => {
|
||||||
|
if (state.nodes === prevState.nodes) return
|
||||||
syncSelectionMaterials()
|
syncSelectionMaterials()
|
||||||
})
|
})
|
||||||
}, [syncSelectionMaterials])
|
}, [syncSelectionMaterials])
|
||||||
|
|||||||
@@ -9,7 +9,15 @@ import { cn } from './../../../lib/utils'
|
|||||||
import useEditor from './../../../store/use-editor'
|
import useEditor from './../../../store/use-editor'
|
||||||
import { ActionButton } from './action-button'
|
import { ActionButton } from './action-button'
|
||||||
|
|
||||||
type ControlId = 'select' | 'box-select' | 'site-edit' | 'build' | 'furnish' | 'zone' | 'delete'
|
type ControlId =
|
||||||
|
| 'select'
|
||||||
|
| 'box-select'
|
||||||
|
| 'site-edit'
|
||||||
|
| 'build'
|
||||||
|
| 'material-paint'
|
||||||
|
| 'furnish'
|
||||||
|
| 'zone'
|
||||||
|
| 'delete'
|
||||||
|
|
||||||
type ControlConfig = {
|
type ControlConfig = {
|
||||||
id: ControlId
|
id: ControlId
|
||||||
@@ -54,6 +62,14 @@ const controls: ControlConfig[] = [
|
|||||||
color: 'hover:bg-green-500/20 hover:text-green-400',
|
color: 'hover:bg-green-500/20 hover:text-green-400',
|
||||||
activeColor: 'bg-green-500/20 text-green-400',
|
activeColor: 'bg-green-500/20 text-green-400',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'material-paint',
|
||||||
|
imageSrc: '/icons/paint.png',
|
||||||
|
label: 'Material Paint',
|
||||||
|
shortcut: 'P',
|
||||||
|
color: 'hover:bg-amber-500/20 hover:text-amber-400',
|
||||||
|
activeColor: 'bg-amber-500/20 text-amber-400',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'furnish',
|
id: 'furnish',
|
||||||
imageSrc: '/icons/couch.png',
|
imageSrc: '/icons/couch.png',
|
||||||
@@ -88,6 +104,7 @@ export function ControlModes() {
|
|||||||
const setPhase = useEditor((state) => state.setPhase)
|
const setPhase = useEditor((state) => state.setPhase)
|
||||||
const setStructureLayer = useEditor((state) => state.setStructureLayer)
|
const setStructureLayer = useEditor((state) => state.setStructureLayer)
|
||||||
const setSelectionTool = useEditor((state) => state.setFloorplanSelectionTool)
|
const setSelectionTool = useEditor((state) => state.setFloorplanSelectionTool)
|
||||||
|
const primeMaterialPaintFromSelection = useEditor((state) => state.primeMaterialPaintFromSelection)
|
||||||
const levelId = useViewer((s) => s.selection.levelId)
|
const levelId = useViewer((s) => s.selection.levelId)
|
||||||
|
|
||||||
// Only subscribe to the primitive `level` number — when walls are added to
|
// Only subscribe to the primitive `level` number — when walls are added to
|
||||||
@@ -112,6 +129,7 @@ export function ControlModes() {
|
|||||||
if (id === 'site-edit') return false
|
if (id === 'site-edit') return false
|
||||||
if (id === 'build')
|
if (id === 'build')
|
||||||
return mode === 'build' && phase === 'structure' && structureLayer === 'elements'
|
return mode === 'build' && phase === 'structure' && structureLayer === 'elements'
|
||||||
|
if (id === 'material-paint') return mode === 'material-paint'
|
||||||
if (id === 'furnish') return mode === 'build' && phase === 'furnish'
|
if (id === 'furnish') return mode === 'build' && phase === 'furnish'
|
||||||
if (id === 'zone')
|
if (id === 'zone')
|
||||||
return mode === 'build' && phase === 'structure' && structureLayer === 'zones'
|
return mode === 'build' && phase === 'structure' && structureLayer === 'zones'
|
||||||
@@ -155,6 +173,15 @@ export function ControlModes() {
|
|||||||
setStructureLayer('elements')
|
setStructureLayer('elements')
|
||||||
setMode('build')
|
setMode('build')
|
||||||
}
|
}
|
||||||
|
} else if (id === 'material-paint') {
|
||||||
|
if (getIsActive('material-paint')) {
|
||||||
|
setMode('select')
|
||||||
|
} else {
|
||||||
|
primeMaterialPaintFromSelection()
|
||||||
|
setPhase('structure')
|
||||||
|
setStructureLayer('elements')
|
||||||
|
setMode('material-paint')
|
||||||
|
}
|
||||||
} else if (id === 'furnish') {
|
} else if (id === 'furnish') {
|
||||||
if (getIsActive('furnish')) {
|
if (getIsActive('furnish')) {
|
||||||
setMode('select')
|
setMode('select')
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useScene } from '@pascal-app/core'
|
||||||
import { AnimatePresence, motion } from 'motion/react'
|
import { AnimatePresence, motion } from 'motion/react'
|
||||||
|
import { useEffect, useMemo } from 'react'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { TooltipProvider } from './../../../components/ui/primitives/tooltip'
|
import { TooltipProvider } from './../../../components/ui/primitives/tooltip'
|
||||||
|
import { MaterialPicker } from './../../../components/ui/controls/material-picker'
|
||||||
import { useReducedMotion } from './../../../hooks/use-reduced-motion'
|
import { useReducedMotion } from './../../../hooks/use-reduced-motion'
|
||||||
|
import { resolvePaintTargetFromSelection } from './../../../lib/material-paint'
|
||||||
import { cn } from './../../../lib/utils'
|
import { cn } from './../../../lib/utils'
|
||||||
import useEditor from './../../../store/use-editor'
|
import useEditor from './../../../store/use-editor'
|
||||||
import { ItemCatalog } from '../item-catalog/item-catalog'
|
import { ItemCatalog } from '../item-catalog/item-catalog'
|
||||||
@@ -12,12 +17,49 @@ import { FurnishTools } from './furnish-tools'
|
|||||||
import { StructureTools } from './structure-tools'
|
import { StructureTools } from './structure-tools'
|
||||||
import { ViewToggles } from './view-toggles'
|
import { ViewToggles } from './view-toggles'
|
||||||
|
|
||||||
|
function PaintMaterialTray() {
|
||||||
|
const activePaintMaterial = useEditor((state) => state.activePaintMaterial)
|
||||||
|
const activePaintTarget = useEditor((state) => state.activePaintTarget)
|
||||||
|
const setActivePaintMaterial = useEditor((state) => state.setActivePaintMaterial)
|
||||||
|
const setActivePaintTarget = useEditor((state) => state.setActivePaintTarget)
|
||||||
|
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||||
|
const nodes = useScene((state) => state.nodes)
|
||||||
|
const selectedId = selectedIds.length === 1 ? (selectedIds[0] ?? null) : null
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const selectedPaintTarget = resolvePaintTargetFromSelection({
|
||||||
|
nodes,
|
||||||
|
selectedId,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (selectedPaintTarget) {
|
||||||
|
setActivePaintTarget(selectedPaintTarget)
|
||||||
|
}
|
||||||
|
}, [nodes, selectedId, setActivePaintTarget])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-[42rem] max-w-[calc(100vw-2rem)]">
|
||||||
|
<MaterialPicker
|
||||||
|
onChange={(material) => {
|
||||||
|
setActivePaintMaterial({ material, sourceTarget: activePaintTarget })
|
||||||
|
}}
|
||||||
|
onSelectMaterialPreset={(materialPreset) => {
|
||||||
|
setActivePaintMaterial({ materialPreset, sourceTarget: activePaintTarget })
|
||||||
|
}}
|
||||||
|
selectedMaterialPreset={activePaintMaterial?.materialPreset}
|
||||||
|
value={activePaintMaterial?.material}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function ActionMenu({ className }: { className?: string }) {
|
export function ActionMenu({ className }: { className?: string }) {
|
||||||
const phase = useEditor((state) => state.phase)
|
const phase = useEditor((state) => state.phase)
|
||||||
const mode = useEditor((state) => state.mode)
|
const mode = useEditor((state) => state.mode)
|
||||||
const tool = useEditor((state) => state.tool)
|
const tool = useEditor((state) => state.tool)
|
||||||
const catalogCategory = useEditor((state) => state.catalogCategory)
|
const catalogCategory = useEditor((state) => state.catalogCategory)
|
||||||
const reducedMotion = useReducedMotion()
|
const reducedMotion = useReducedMotion()
|
||||||
|
const showPaintTray = useMemo(() => mode === 'material-paint', [mode])
|
||||||
const transition = reducedMotion
|
const transition = reducedMotion
|
||||||
? { duration: 0 }
|
? { duration: 0 }
|
||||||
: { type: 'spring' as const, bounce: 0.2, duration: 0.4 }
|
: { type: 'spring' as const, bounce: 0.2, duration: 0.4 }
|
||||||
@@ -138,6 +180,38 @@ export function ActionMenu({ className }: { className?: string }) {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{showPaintTray && (
|
||||||
|
<motion.div
|
||||||
|
animate={{
|
||||||
|
opacity: 1,
|
||||||
|
maxHeight: 96,
|
||||||
|
paddingTop: 8,
|
||||||
|
paddingBottom: 8,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
}}
|
||||||
|
className={cn('overflow-hidden border-border border-b px-3')}
|
||||||
|
exit={{
|
||||||
|
opacity: 0,
|
||||||
|
maxHeight: 0,
|
||||||
|
paddingTop: 0,
|
||||||
|
paddingBottom: 0,
|
||||||
|
borderBottomWidth: 0,
|
||||||
|
}}
|
||||||
|
initial={{
|
||||||
|
opacity: 0,
|
||||||
|
maxHeight: 0,
|
||||||
|
paddingTop: 0,
|
||||||
|
paddingBottom: 0,
|
||||||
|
borderBottomWidth: 0,
|
||||||
|
}}
|
||||||
|
transition={transition}
|
||||||
|
>
|
||||||
|
<PaintMaterialTray />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
{/* Control Mode Row - Always visible, centered */}
|
{/* Control Mode Row - Always visible, centered */}
|
||||||
<div className="flex items-center justify-center gap-1 px-2 py-1.5">
|
<div className="flex items-center justify-center gap-1 px-2 py-1.5">
|
||||||
<ControlModes />
|
<ControlModes />
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
Moon,
|
Moon,
|
||||||
MousePointer2,
|
MousePointer2,
|
||||||
Package,
|
Package,
|
||||||
|
PaintBucket,
|
||||||
PencilLine,
|
PencilLine,
|
||||||
Plus,
|
Plus,
|
||||||
Redo2,
|
Redo2,
|
||||||
@@ -49,6 +50,7 @@ export function EditorCommands() {
|
|||||||
const setMode = useEditor((s) => s.setMode)
|
const setMode = useEditor((s) => s.setMode)
|
||||||
const setTool = useEditor((s) => s.setTool)
|
const setTool = useEditor((s) => s.setTool)
|
||||||
const setStructureLayer = useEditor((s) => s.setStructureLayer)
|
const setStructureLayer = useEditor((s) => s.setStructureLayer)
|
||||||
|
const primeMaterialPaintFromSelection = useEditor((s) => s.primeMaterialPaintFromSelection)
|
||||||
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
||||||
const setPreviewMode = useEditor((s) => s.setPreviewMode)
|
const setPreviewMode = useEditor((s) => s.setPreviewMode)
|
||||||
|
|
||||||
@@ -150,6 +152,21 @@ export function EditorCommands() {
|
|||||||
useScene.getState().deleteNodes(selectedIds as any[])
|
useScene.getState().deleteNodes(selectedIds as any[])
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'editor.mode.material-paint',
|
||||||
|
label: 'Material Paint',
|
||||||
|
group: 'Scene',
|
||||||
|
icon: <PaintBucket className="h-4 w-4" />,
|
||||||
|
keywords: ['paint', 'material', 'texture', 'bucket', 'surface'],
|
||||||
|
shortcut: ['P'],
|
||||||
|
execute: () =>
|
||||||
|
run(() => {
|
||||||
|
primeMaterialPaintFromSelection()
|
||||||
|
setPhase('structure')
|
||||||
|
setStructureLayer('elements')
|
||||||
|
setMode('material-paint')
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
// ── Levels ───────────────────────────────────────────────────────────
|
// ── Levels ───────────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
@@ -355,7 +372,7 @@ export function EditorCommands() {
|
|||||||
icon: <Box className="h-4 w-4" />,
|
icon: <Box className="h-4 w-4" />,
|
||||||
keywords: ['export', 'glb', 'gltf', '3d', 'model', 'download'],
|
keywords: ['export', 'glb', 'gltf', '3d', 'model', 'download'],
|
||||||
execute: () => run(() => exportScene()),
|
execute: () => run(() => exportScene()),
|
||||||
} as const,
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,59 +1,117 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getMaterialsForTarget,
|
getCatalogMaterialById,
|
||||||
|
getLibraryMaterialIdFromRef,
|
||||||
|
getMaterialsForCategory,
|
||||||
|
MATERIAL_CATEGORIES,
|
||||||
toLibraryMaterialRef,
|
toLibraryMaterialRef,
|
||||||
type MaterialSchema,
|
type MaterialSchema,
|
||||||
type MaterialTarget,
|
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import useEditor from '../../../store/use-editor'
|
||||||
|
|
||||||
type MaterialPickerProps = {
|
type MaterialPickerProps = {
|
||||||
nodeType?: MaterialTarget
|
|
||||||
value?: MaterialSchema
|
value?: MaterialSchema
|
||||||
selectedMaterialPreset?: string
|
selectedMaterialPreset?: string
|
||||||
onChange?: (material: MaterialSchema) => void
|
onChange?: (material: MaterialSchema) => void
|
||||||
onSelectMaterialPreset?: (materialPreset: string) => void
|
onSelectMaterialPreset?: (materialPreset: string) => void
|
||||||
hideSideControl?: boolean
|
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MaterialPicker({
|
export function MaterialPicker({
|
||||||
nodeType,
|
|
||||||
value,
|
value,
|
||||||
selectedMaterialPreset,
|
selectedMaterialPreset,
|
||||||
onChange,
|
onChange,
|
||||||
onSelectMaterialPreset,
|
onSelectMaterialPreset,
|
||||||
hideSideControl = false,
|
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: MaterialPickerProps) {
|
}: MaterialPickerProps) {
|
||||||
|
const setPaintPanelOpen = useEditor((state) => state.setPaintPanelOpen)
|
||||||
const [showCustom, setShowCustom] = useState<boolean>(!!value?.properties)
|
const [showCustom, setShowCustom] = useState<boolean>(!!value?.properties)
|
||||||
const catalogItems = nodeType ? getMaterialsForTarget(nodeType) : []
|
const [selectedCategory, setSelectedCategory] = useState<(typeof MATERIAL_CATEGORIES)[number]>(
|
||||||
|
MATERIAL_CATEGORIES[0],
|
||||||
|
)
|
||||||
|
const catalogScrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
const categoryScrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
const catalogItems =
|
||||||
|
selectedCategory === 'other'
|
||||||
|
? getMaterialsForCategory('other')
|
||||||
|
: getMaterialsForCategory(selectedCategory)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setShowCustom(!!value?.properties && !selectedMaterialPreset)
|
setShowCustom(!!value?.properties && !selectedMaterialPreset)
|
||||||
}, [selectedMaterialPreset, value?.properties])
|
}, [selectedMaterialPreset, value?.properties])
|
||||||
|
|
||||||
const currentProps = value?.properties || {
|
useEffect(() => {
|
||||||
color: '#ffffff',
|
if (!selectedMaterialPreset && value?.properties) {
|
||||||
roughness: 0.5,
|
setSelectedCategory('other')
|
||||||
metalness: 0,
|
return
|
||||||
opacity: 1,
|
}
|
||||||
transparent: false,
|
|
||||||
side: 'front' as const,
|
const catalogId =
|
||||||
}
|
getLibraryMaterialIdFromRef(selectedMaterialPreset) ?? value?.id ?? undefined
|
||||||
|
const selectedCatalogEntry = getCatalogMaterialById(catalogId)
|
||||||
|
if (selectedCatalogEntry?.category) {
|
||||||
|
setSelectedCategory(selectedCatalogEntry.category)
|
||||||
|
}
|
||||||
|
}, [selectedMaterialPreset, value?.id])
|
||||||
|
|
||||||
const selectedCatalogId =
|
const selectedCatalogId =
|
||||||
selectedMaterialPreset ?? (value?.id ? toLibraryMaterialRef(value.id) : undefined)
|
selectedMaterialPreset ?? (value?.id ? toLibraryMaterialRef(value.id) : undefined)
|
||||||
|
|
||||||
const handleCatalogSelect = (materialId: string) => {
|
const handleCatalogSelect = (materialId: string) => {
|
||||||
if (disabled) return
|
if (disabled) return
|
||||||
setShowCustom(false)
|
setShowCustom(false)
|
||||||
|
setPaintPanelOpen(false)
|
||||||
onSelectMaterialPreset?.(toLibraryMaterialRef(materialId))
|
onSelectMaterialPreset?.(toLibraryMaterialRef(materialId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = catalogScrollRef.current
|
||||||
|
if (!container) return
|
||||||
|
|
||||||
|
const handleWheel = (event: WheelEvent) => {
|
||||||
|
const deltaX = event.deltaX
|
||||||
|
const deltaY = event.deltaY
|
||||||
|
const nextScrollLeft = container.scrollLeft + deltaX + deltaY
|
||||||
|
|
||||||
|
if (nextScrollLeft === container.scrollLeft) return
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
container.scrollLeft = nextScrollLeft
|
||||||
|
}
|
||||||
|
|
||||||
|
container.addEventListener('wheel', handleWheel, { passive: false })
|
||||||
|
return () => {
|
||||||
|
container.removeEventListener('wheel', handleWheel)
|
||||||
|
}
|
||||||
|
}, [catalogItems.length, onChange, showCustom])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = categoryScrollRef.current
|
||||||
|
if (!container) return
|
||||||
|
|
||||||
|
const handleWheel = (event: WheelEvent) => {
|
||||||
|
const deltaX = event.deltaX
|
||||||
|
const deltaY = event.deltaY
|
||||||
|
const nextScrollLeft = container.scrollLeft + deltaX + deltaY
|
||||||
|
|
||||||
|
if (nextScrollLeft === container.scrollLeft) return
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
container.scrollLeft = nextScrollLeft
|
||||||
|
}
|
||||||
|
|
||||||
|
container.addEventListener('wheel', handleWheel, { passive: false })
|
||||||
|
return () => {
|
||||||
|
container.removeEventListener('wheel', handleWheel)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleCustomOpen = () => {
|
const handleCustomOpen = () => {
|
||||||
if (disabled) return
|
if (disabled) return
|
||||||
setShowCustom(true)
|
setShowCustom(true)
|
||||||
|
setPaintPanelOpen(true)
|
||||||
onChange?.({
|
onChange?.({
|
||||||
preset: 'custom',
|
preset: 'custom',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -67,159 +125,88 @@ export function MaterialPicker({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePropertyChange = (
|
|
||||||
prop: keyof typeof currentProps,
|
|
||||||
val: (typeof currentProps)[keyof typeof currentProps],
|
|
||||||
) => {
|
|
||||||
if (disabled) return
|
|
||||||
onChange?.({
|
|
||||||
preset: 'custom',
|
|
||||||
properties: {
|
|
||||||
...currentProps,
|
|
||||||
[prop]: val,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`space-y-3 ${disabled ? 'pointer-events-none opacity-50' : ''}`}>
|
<div className={`min-w-0 space-y-3 ${disabled ? 'pointer-events-none opacity-50' : ''}`}>
|
||||||
{(catalogItems.length > 0 || onChange) && (
|
{(catalogItems.length > 0 || onChange) && (
|
||||||
<div className="space-y-2">
|
<div className="min-w-0 space-y-1">
|
||||||
{catalogItems.length > 0 ? (
|
<div
|
||||||
<div className="text-gray-500 text-xs uppercase tracking-[0.16em]">Library</div>
|
className="w-full max-w-full overflow-x-auto overflow-y-hidden"
|
||||||
) : null}
|
ref={categoryScrollRef}
|
||||||
<div className="flex flex-wrap gap-1.5">
|
style={{ msOverflowStyle: 'none', scrollbarWidth: 'none' }}
|
||||||
{catalogItems.map((item) => (
|
>
|
||||||
<button
|
<div className="flex min-w-max gap-1 pb-1">
|
||||||
className={`h-14 w-14 shrink-0 overflow-hidden rounded-lg border transition-all ${
|
{MATERIAL_CATEGORIES.map((category) => (
|
||||||
selectedCatalogId === toLibraryMaterialRef(item.id)
|
<button
|
||||||
? 'border-blue-500 ring-2 ring-blue-500/30'
|
className={`shrink-0 px-2 font-medium text-[11px] uppercase tracking-[0.12em] transition-all ${
|
||||||
: 'border-gray-300 hover:border-gray-400'
|
selectedCategory === category
|
||||||
}`}
|
? 'bg-transparent text-foreground'
|
||||||
key={item.id}
|
: 'bg-transparent text-muted-foreground opacity-70 hover:text-foreground hover:opacity-100'
|
||||||
onClick={() => handleCatalogSelect(item.id)}
|
}`}
|
||||||
title={item.label}
|
key={category}
|
||||||
type="button"
|
onClick={() => {
|
||||||
>
|
setSelectedCategory(category)
|
||||||
{item.previewThumbnailUrl ? (
|
if (showCustom) {
|
||||||
<img
|
setShowCustom(false)
|
||||||
alt={item.label}
|
}
|
||||||
className="h-full w-full object-cover"
|
if (category !== 'other') {
|
||||||
src={item.previewThumbnailUrl}
|
setPaintPanelOpen(false)
|
||||||
/>
|
}
|
||||||
) : item.previewColor ? (
|
}}
|
||||||
<div className="h-full w-full" style={{ backgroundColor: item.previewColor }} />
|
type="button"
|
||||||
) : (
|
>
|
||||||
<div className="h-full w-full bg-gray-100" />
|
{category.charAt(0).toUpperCase() + category.slice(1)}
|
||||||
)}
|
</button>
|
||||||
</button>
|
))}
|
||||||
))}
|
|
||||||
{onChange ? (
|
|
||||||
<button
|
|
||||||
className={`flex h-14 w-14 shrink-0 items-center justify-center rounded-lg border text-[10px] font-medium transition-all ${
|
|
||||||
showCustom
|
|
||||||
? 'border-blue-500 bg-blue-50 text-blue-700 ring-2 ring-blue-500/30'
|
|
||||||
: 'border-gray-300 bg-white text-gray-500 hover:border-gray-400'
|
|
||||||
}`}
|
|
||||||
onClick={handleCustomOpen}
|
|
||||||
title="Custom"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Custom
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showCustom && onChange && (
|
|
||||||
<div className="space-y-2 pt-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label className="w-16 text-gray-500 text-xs">Color</label>
|
|
||||||
<input
|
|
||||||
className="h-7 w-12 cursor-pointer rounded border border-gray-300"
|
|
||||||
onChange={(e) => handlePropertyChange('color', e.target.value)}
|
|
||||||
type="color"
|
|
||||||
value={currentProps.color}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
className="h-7 flex-1 rounded border border-gray-300 px-2 text-xs"
|
|
||||||
onChange={(e) => handlePropertyChange('color', e.target.value)}
|
|
||||||
type="text"
|
|
||||||
value={currentProps.color}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label className="w-16 text-gray-500 text-xs">Roughness</label>
|
|
||||||
<input
|
|
||||||
className="h-1.5 flex-1 cursor-pointer appearance-none rounded-lg bg-gray-200"
|
|
||||||
max={1}
|
|
||||||
min={0}
|
|
||||||
onChange={(e) => handlePropertyChange('roughness', Number.parseFloat(e.target.value))}
|
|
||||||
step={0.01}
|
|
||||||
type="range"
|
|
||||||
value={currentProps.roughness}
|
|
||||||
/>
|
|
||||||
<span className="w-8 text-right text-gray-400 text-xs">
|
|
||||||
{currentProps.roughness.toFixed(2)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label className="w-16 text-gray-500 text-xs">Metalness</label>
|
|
||||||
<input
|
|
||||||
className="h-1.5 flex-1 cursor-pointer appearance-none rounded-lg bg-gray-200"
|
|
||||||
max={1}
|
|
||||||
min={0}
|
|
||||||
onChange={(e) => handlePropertyChange('metalness', Number.parseFloat(e.target.value))}
|
|
||||||
step={0.01}
|
|
||||||
type="range"
|
|
||||||
value={currentProps.metalness}
|
|
||||||
/>
|
|
||||||
<span className="w-8 text-right text-gray-400 text-xs">
|
|
||||||
{currentProps.metalness.toFixed(2)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label className="w-16 text-gray-500 text-xs">Opacity</label>
|
|
||||||
<input
|
|
||||||
className="h-1.5 flex-1 cursor-pointer appearance-none rounded-lg bg-gray-200"
|
|
||||||
max={1}
|
|
||||||
min={0}
|
|
||||||
onChange={(e) => {
|
|
||||||
const opacity = Number.parseFloat(e.target.value)
|
|
||||||
handlePropertyChange('opacity', opacity)
|
|
||||||
if (opacity < 1 && !currentProps.transparent) {
|
|
||||||
handlePropertyChange('transparent', true)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
step={0.01}
|
|
||||||
type="range"
|
|
||||||
value={currentProps.opacity}
|
|
||||||
/>
|
|
||||||
<span className="w-8 text-right text-gray-400 text-xs">
|
|
||||||
{currentProps.opacity.toFixed(2)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!hideSideControl && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label className="w-16 text-gray-500 text-xs">Side</label>
|
|
||||||
<select
|
|
||||||
className="h-7 flex-1 rounded border border-gray-300 px-2 text-xs"
|
|
||||||
onChange={(e) =>
|
|
||||||
handlePropertyChange('side', e.target.value as 'front' | 'back' | 'double')
|
|
||||||
}
|
|
||||||
value={currentProps.side}
|
|
||||||
>
|
|
||||||
<option value="front">Front</option>
|
|
||||||
<option value="back">Back</option>
|
|
||||||
<option value="double">Double</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-full overflow-x-auto overflow-y-hidden"
|
||||||
|
ref={catalogScrollRef}
|
||||||
|
style={{ msOverflowStyle: 'none', scrollbarWidth: 'none' }}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-max gap-1.5 pb-1">
|
||||||
|
{catalogItems.map((item) => (
|
||||||
|
<button
|
||||||
|
className={`relative h-14 w-14 shrink-0 overflow-hidden rounded-lg border transition-all ${
|
||||||
|
selectedCatalogId === toLibraryMaterialRef(item.id)
|
||||||
|
? 'border-blue-500 ring-2 ring-blue-500/30'
|
||||||
|
: 'border-gray-300 hover:border-gray-400'
|
||||||
|
}`}
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => handleCatalogSelect(item.id)}
|
||||||
|
title={item.label}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div className="pointer-events-none absolute inset-0 rounded-[inherit] ring-1 ring-inset ring-white/12" />
|
||||||
|
{item.previewThumbnailUrl ? (
|
||||||
|
<img
|
||||||
|
alt={item.label}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
src={item.previewThumbnailUrl}
|
||||||
|
/>
|
||||||
|
) : item.previewColor ? (
|
||||||
|
<div className="h-full w-full" style={{ backgroundColor: item.previewColor }} />
|
||||||
|
) : (
|
||||||
|
<div className="h-full w-full bg-gray-100" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{selectedCategory === 'other' && onChange ? (
|
||||||
|
<button
|
||||||
|
className={`flex h-14 w-14 shrink-0 items-center justify-center rounded-lg border text-[10px] font-medium transition-all ${
|
||||||
|
showCustom
|
||||||
|
? 'border-blue-500 ring-2 ring-blue-500/30'
|
||||||
|
: 'border-gray-300 hover:border-gray-400'
|
||||||
|
}`}
|
||||||
|
onClick={handleCustomOpen}
|
||||||
|
title="Custom"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Custom
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { type AnyNode, type CeilingNode, type MaterialSchema, useScene } from '@pascal-app/core'
|
import { type AnyNode, type CeilingNode, useScene } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { Edit, Move, Plus, Trash2 } from 'lucide-react'
|
import { Edit, Move, Plus, Trash2 } from 'lucide-react'
|
||||||
import { useCallback, useEffect } from 'react'
|
import { useCallback, useEffect } from 'react'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||||
import { MaterialPicker } from '../controls/material-picker'
|
|
||||||
import { PanelSection } from '../controls/panel-section'
|
import { PanelSection } from '../controls/panel-section'
|
||||||
import { SliderControl } from '../controls/slider-control'
|
import { SliderControl } from '../controls/slider-control'
|
||||||
import { PanelWrapper } from './panel-wrapper'
|
import { PanelWrapper } from './panel-wrapper'
|
||||||
@@ -32,20 +31,6 @@ export function CeilingPanel() {
|
|||||||
[selectedId, updateNode],
|
[selectedId, updateNode],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleMaterialChange = useCallback(
|
|
||||||
(material: MaterialSchema) => {
|
|
||||||
handleUpdate({ material, materialPreset: undefined })
|
|
||||||
},
|
|
||||||
[handleUpdate],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleMaterialPresetChange = useCallback(
|
|
||||||
(materialPreset: string) => {
|
|
||||||
handleUpdate({ materialPreset, material: undefined })
|
|
||||||
},
|
|
||||||
[handleUpdate],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
setEditingHole(null)
|
setEditingHole(null)
|
||||||
@@ -257,15 +242,6 @@ export function CeilingPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
|
|
||||||
<PanelSection title="Material">
|
|
||||||
<MaterialPicker
|
|
||||||
nodeType="ceiling"
|
|
||||||
onChange={handleMaterialChange}
|
|
||||||
onSelectMaterialPreset={handleMaterialPresetChange}
|
|
||||||
selectedMaterialPreset={node.materialPreset}
|
|
||||||
value={node.material}
|
|
||||||
/>
|
|
||||||
</PanelSection>
|
|
||||||
<ActionGroup>
|
<ActionGroup>
|
||||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||||
</ActionGroup>
|
</ActionGroup>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
DoorNode,
|
DoorNode,
|
||||||
emitter,
|
emitter,
|
||||||
type MaterialSchema,
|
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
@@ -15,7 +14,6 @@ import { usePresetsAdapter } from '../../../contexts/presets-context'
|
|||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||||
import { MaterialPicker } from '../controls/material-picker'
|
|
||||||
import { MetricControl } from '../controls/metric-control'
|
import { MetricControl } from '../controls/metric-control'
|
||||||
import { PanelSection } from '../controls/panel-section'
|
import { PanelSection } from '../controls/panel-section'
|
||||||
import { SegmentedControl } from '../controls/segmented-control'
|
import { SegmentedControl } from '../controls/segmented-control'
|
||||||
@@ -46,13 +44,6 @@ export function DoorPanel() {
|
|||||||
[selectedId, updateNode],
|
[selectedId, updateNode],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleMaterialChange = useCallback(
|
|
||||||
(material: MaterialSchema) => {
|
|
||||||
handleUpdate({ material })
|
|
||||||
},
|
|
||||||
[handleUpdate],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
}, [setSelection])
|
}, [setSelection])
|
||||||
@@ -592,9 +583,6 @@ export function DoorPanel() {
|
|||||||
/>
|
/>
|
||||||
</ActionGroup>
|
</ActionGroup>
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
<PanelSection title="Material">
|
|
||||||
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
|
|
||||||
</PanelSection>
|
|
||||||
</PanelWrapper>
|
</PanelWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type AnyNode,
|
type AnyNode,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
@@ -11,9 +12,11 @@ import {
|
|||||||
normalizeWallCurveOffset,
|
normalizeWallCurveOffset,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
|
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { Move, Spline } from 'lucide-react'
|
import { Move, Spline } from 'lucide-react'
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
|
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||||
@@ -83,33 +86,9 @@ export function FencePanel() {
|
|||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
}, [setSelection])
|
}, [setSelection])
|
||||||
|
|
||||||
const handleMove = useCallback(() => {
|
|
||||||
if (!node) return
|
|
||||||
sfxEmitter.emit('sfx:item-pick')
|
|
||||||
setMovingNode(node)
|
|
||||||
setSelection({ selectedIds: [] })
|
|
||||||
}, [node, setMovingNode, setSelection])
|
|
||||||
|
|
||||||
const handleCurve = useCallback(() => {
|
|
||||||
if (!node) return
|
|
||||||
sfxEmitter.emit('sfx:item-pick')
|
|
||||||
setCurvingFence(node)
|
|
||||||
setSelection({ selectedIds: [] })
|
|
||||||
}, [node, setCurvingFence, setSelection])
|
|
||||||
|
|
||||||
const handleMaterialPresetChange = useCallback(
|
|
||||||
(materialPreset: string) => {
|
|
||||||
handleUpdate({ materialPreset, material: undefined })
|
|
||||||
},
|
|
||||||
[handleUpdate],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleCustomMaterialChange = useCallback(
|
|
||||||
(material: MaterialSchema) => {
|
|
||||||
handleUpdate({ material, materialPreset: undefined })
|
|
||||||
},
|
|
||||||
[handleUpdate],
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!(node && node.type === 'fence' && selectedId && selectedCount === 1)) return null
|
if (!(node && node.type === 'fence' && selectedId && selectedCount === 1)) return null
|
||||||
|
|
||||||
@@ -243,27 +222,6 @@ export function FencePanel() {
|
|||||||
value={node.edgeInset}
|
value={node.edgeInset}
|
||||||
/>
|
/>
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
|
|
||||||
<PanelSection title="Material">
|
|
||||||
<MaterialPicker
|
|
||||||
nodeType="fence"
|
|
||||||
onChange={handleCustomMaterialChange}
|
|
||||||
onSelectMaterialPreset={handleMaterialPresetChange}
|
|
||||||
selectedMaterialPreset={node.materialPreset}
|
|
||||||
value={node.material}
|
|
||||||
/>
|
|
||||||
</PanelSection>
|
|
||||||
|
|
||||||
<PanelSection title="Actions">
|
|
||||||
<ActionGroup>
|
|
||||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
|
||||||
<ActionButton
|
|
||||||
icon={<Spline className="h-3.5 w-3.5" />}
|
|
||||||
label="Curve"
|
|
||||||
onClick={handleCurve}
|
|
||||||
/>
|
|
||||||
</ActionGroup>
|
|
||||||
</PanelSection>
|
|
||||||
</PanelWrapper>
|
</PanelWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import useEditor from '../../../store/use-editor'
|
||||||
|
import { Input } from '../primitives/input'
|
||||||
|
import { PanelSection } from '../controls/panel-section'
|
||||||
|
import { PanelWrapper } from './panel-wrapper'
|
||||||
|
|
||||||
|
function buildDefaultCustomMaterial() {
|
||||||
|
return {
|
||||||
|
preset: 'custom' as const,
|
||||||
|
properties: {
|
||||||
|
color: '#ffffff',
|
||||||
|
roughness: 0.5,
|
||||||
|
metalness: 0,
|
||||||
|
opacity: 1,
|
||||||
|
transparent: false,
|
||||||
|
side: 'front' as const,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PaintPanel() {
|
||||||
|
const activePaintMaterial = useEditor((state) => state.activePaintMaterial)
|
||||||
|
const activePaintTarget = useEditor((state) => state.activePaintTarget)
|
||||||
|
const setActivePaintMaterial = useEditor((state) => state.setActivePaintMaterial)
|
||||||
|
const setPaintPanelOpen = useEditor((state) => state.setPaintPanelOpen)
|
||||||
|
|
||||||
|
const customMaterial =
|
||||||
|
activePaintMaterial?.material?.properties && !activePaintMaterial.materialPreset
|
||||||
|
? activePaintMaterial.material
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (!customMaterial) return null
|
||||||
|
|
||||||
|
const currentProps = customMaterial.properties ?? buildDefaultCustomMaterial().properties
|
||||||
|
|
||||||
|
const updateCustomMaterial = (
|
||||||
|
updates: Partial<typeof currentProps>,
|
||||||
|
nextTransparent = currentProps.transparent,
|
||||||
|
) => {
|
||||||
|
setActivePaintMaterial({
|
||||||
|
material: {
|
||||||
|
preset: 'custom',
|
||||||
|
properties: {
|
||||||
|
...currentProps,
|
||||||
|
...updates,
|
||||||
|
transparent: nextTransparent,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sourceTarget: activePaintMaterial?.sourceTarget ?? activePaintTarget,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PanelWrapper
|
||||||
|
onClose={() => setPaintPanelOpen(false)}
|
||||||
|
title="Material"
|
||||||
|
width={320}
|
||||||
|
>
|
||||||
|
<PanelSection title="Custom Material">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
|
||||||
|
Color
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
className="h-10 w-14 cursor-pointer rounded-md border border-input bg-transparent"
|
||||||
|
onChange={(e) => updateCustomMaterial({ color: e.target.value })}
|
||||||
|
type="color"
|
||||||
|
value={currentProps.color}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
onChange={(e) => updateCustomMaterial({ color: e.target.value })}
|
||||||
|
value={currentProps.color}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
|
||||||
|
Roughness
|
||||||
|
</label>
|
||||||
|
<span className="font-mono text-muted-foreground text-xs">
|
||||||
|
{currentProps.roughness.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-accent"
|
||||||
|
max={1}
|
||||||
|
min={0}
|
||||||
|
onChange={(e) => updateCustomMaterial({ roughness: Number.parseFloat(e.target.value) })}
|
||||||
|
step={0.01}
|
||||||
|
type="range"
|
||||||
|
value={currentProps.roughness}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
|
||||||
|
Metalness
|
||||||
|
</label>
|
||||||
|
<span className="font-mono text-muted-foreground text-xs">
|
||||||
|
{currentProps.metalness.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-accent"
|
||||||
|
max={1}
|
||||||
|
min={0}
|
||||||
|
onChange={(e) => updateCustomMaterial({ metalness: Number.parseFloat(e.target.value) })}
|
||||||
|
step={0.01}
|
||||||
|
type="range"
|
||||||
|
value={currentProps.metalness}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
|
||||||
|
Opacity
|
||||||
|
</label>
|
||||||
|
<span className="font-mono text-muted-foreground text-xs">
|
||||||
|
{currentProps.opacity.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-accent"
|
||||||
|
max={1}
|
||||||
|
min={0}
|
||||||
|
onChange={(e) => {
|
||||||
|
const opacity = Number.parseFloat(e.target.value)
|
||||||
|
updateCustomMaterial({ opacity }, opacity < 1 || currentProps.transparent)
|
||||||
|
}}
|
||||||
|
step={0.01}
|
||||||
|
type="range"
|
||||||
|
value={currentProps.opacity}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
|
||||||
|
Side
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-input/30"
|
||||||
|
onChange={(e) =>
|
||||||
|
updateCustomMaterial({ side: e.target.value as 'front' | 'back' | 'double' })
|
||||||
|
}
|
||||||
|
value={currentProps.side}
|
||||||
|
>
|
||||||
|
<option value="front">Front</option>
|
||||||
|
<option value="back">Back</option>
|
||||||
|
<option value="double">Double</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PanelSection>
|
||||||
|
</PanelWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { CeilingPanel } from './ceiling-panel'
|
|||||||
import { DoorPanel } from './door-panel'
|
import { DoorPanel } from './door-panel'
|
||||||
import { FencePanel } from './fence-panel'
|
import { FencePanel } from './fence-panel'
|
||||||
import { ItemPanel } from './item-panel'
|
import { ItemPanel } from './item-panel'
|
||||||
|
import { PaintPanel } from './paint-panel'
|
||||||
import { ReferencePanel } from './reference-panel'
|
import { ReferencePanel } from './reference-panel'
|
||||||
import { RoofPanel } from './roof-panel'
|
import { RoofPanel } from './roof-panel'
|
||||||
import { RoofSegmentPanel } from './roof-segment-panel'
|
import { RoofSegmentPanel } from './roof-segment-panel'
|
||||||
@@ -19,6 +20,9 @@ import { WindowPanel } from './window-panel'
|
|||||||
export function PanelManager() {
|
export function PanelManager() {
|
||||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||||
const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
|
const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
|
||||||
|
const isPaintPanelOpen = useEditor((s) => s.isPaintPanelOpen)
|
||||||
|
const mode = useEditor((s) => s.mode)
|
||||||
|
const activePaintMaterial = useEditor((s) => s.activePaintMaterial)
|
||||||
// Only subscribe to the *type* of the single-selected node — string primitive
|
// Only subscribe to the *type* of the single-selected node — string primitive
|
||||||
// so we don't re-render on unrelated scene mutations.
|
// so we don't re-render on unrelated scene mutations.
|
||||||
const selectedNodeType = useScene((s) => {
|
const selectedNodeType = useScene((s) => {
|
||||||
@@ -32,6 +36,15 @@ export function PanelManager() {
|
|||||||
return <ReferencePanel />
|
return <ReferencePanel />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
isPaintPanelOpen &&
|
||||||
|
mode === 'material-paint' &&
|
||||||
|
activePaintMaterial?.material?.properties &&
|
||||||
|
!activePaintMaterial.materialPreset
|
||||||
|
) {
|
||||||
|
return <PaintPanel />
|
||||||
|
}
|
||||||
|
|
||||||
// Show appropriate panel based on selected node type
|
// Show appropriate panel based on selected node type
|
||||||
if (selectedNodeType) {
|
if (selectedNodeType) {
|
||||||
switch (selectedNodeType) {
|
switch (selectedNodeType) {
|
||||||
|
|||||||
@@ -3,10 +3,9 @@
|
|||||||
import {
|
import {
|
||||||
type AnyNode,
|
type AnyNode,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
getEffectiveRoofSurfaceMaterial,
|
|
||||||
type MaterialSchema,
|
|
||||||
type RoofNode,
|
type RoofNode,
|
||||||
type RoofSurfaceMaterialRole,
|
type RoofSurfaceMaterialRole,
|
||||||
|
RoofNode as RoofNodeSchema,
|
||||||
type RoofSegmentNode,
|
type RoofSegmentNode,
|
||||||
RoofSegmentNode as RoofSegmentNodeSchema,
|
RoofSegmentNode as RoofSegmentNodeSchema,
|
||||||
useScene,
|
useScene,
|
||||||
@@ -19,44 +18,16 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
|
|||||||
import { duplicateRoofSubtree } from '../../../lib/roof-duplication'
|
import { duplicateRoofSubtree } from '../../../lib/roof-duplication'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||||
import { MaterialPicker } from '../controls/material-picker'
|
|
||||||
import { PanelSection } from '../controls/panel-section'
|
import { PanelSection } from '../controls/panel-section'
|
||||||
import { SliderControl } from '../controls/slider-control'
|
import { SliderControl } from '../controls/slider-control'
|
||||||
import { PanelWrapper } from './panel-wrapper'
|
import { PanelWrapper } from './panel-wrapper'
|
||||||
|
|
||||||
function buildRoofSurfaceMaterialPatch(
|
|
||||||
node: RoofNode,
|
|
||||||
targetRole: RoofSurfaceMaterialRole,
|
|
||||||
material: MaterialSchema | undefined,
|
|
||||||
materialPreset: string | undefined,
|
|
||||||
): Partial<RoofNode> {
|
|
||||||
const nextSurfaceMaterial = { material, materialPreset }
|
|
||||||
const nextTop =
|
|
||||||
targetRole === 'top' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'top')
|
|
||||||
const nextEdge =
|
|
||||||
targetRole === 'edge' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'edge')
|
|
||||||
const nextWall =
|
|
||||||
targetRole === 'wall' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'wall')
|
|
||||||
|
|
||||||
return {
|
|
||||||
topMaterial: nextTop.material,
|
|
||||||
topMaterialPreset: nextTop.materialPreset,
|
|
||||||
edgeMaterial: nextEdge.material,
|
|
||||||
edgeMaterialPreset: nextEdge.materialPreset,
|
|
||||||
wallMaterial: nextWall.material,
|
|
||||||
wallMaterialPreset: nextWall.materialPreset,
|
|
||||||
material: undefined,
|
|
||||||
materialPreset: undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RoofPanel() {
|
export function RoofPanel() {
|
||||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||||
const setSelection = useViewer((s) => s.setSelection)
|
const setSelection = useViewer((s) => s.setSelection)
|
||||||
const updateNode = useScene((s) => s.updateNode)
|
const updateNode = useScene((s) => s.updateNode)
|
||||||
const createNode = useScene((s) => s.createNode)
|
const createNode = useScene((s) => s.createNode)
|
||||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||||
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
|
|
||||||
|
|
||||||
const node = useScene((s) =>
|
const node = useScene((s) =>
|
||||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined,
|
selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined,
|
||||||
@@ -79,33 +50,6 @@ export function RoofPanel() {
|
|||||||
[selectedId, updateNode],
|
[selectedId, updateNode],
|
||||||
)
|
)
|
||||||
|
|
||||||
const materialTargetRole =
|
|
||||||
selectedMaterialTarget &&
|
|
||||||
selectedMaterialTarget.nodeId === node?.id &&
|
|
||||||
(selectedMaterialTarget.role === 'top' ||
|
|
||||||
selectedMaterialTarget.role === 'edge' ||
|
|
||||||
selectedMaterialTarget.role === 'wall')
|
|
||||||
? selectedMaterialTarget.role
|
|
||||||
: null
|
|
||||||
const materialPickerValue =
|
|
||||||
node && materialTargetRole ? getEffectiveRoofSurfaceMaterial(node, materialTargetRole) : {}
|
|
||||||
|
|
||||||
const handleTargetedMaterialChange = useCallback(
|
|
||||||
(material: MaterialSchema) => {
|
|
||||||
if (!node || !materialTargetRole) return
|
|
||||||
handleUpdate(buildRoofSurfaceMaterialPatch(node, materialTargetRole, material, undefined))
|
|
||||||
},
|
|
||||||
[handleUpdate, materialTargetRole, node],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleTargetedMaterialPresetChange = useCallback(
|
|
||||||
(materialPreset: string) => {
|
|
||||||
if (!node || !materialTargetRole) return
|
|
||||||
handleUpdate(buildRoofSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset))
|
|
||||||
},
|
|
||||||
[handleUpdate, materialTargetRole, node],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
}, [setSelection])
|
}, [setSelection])
|
||||||
@@ -281,22 +225,6 @@ export function RoofPanel() {
|
|||||||
/>
|
/>
|
||||||
</ActionGroup>
|
</ActionGroup>
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
<PanelSection title="Material">
|
|
||||||
{!materialTargetRole ? (
|
|
||||||
<div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
|
|
||||||
Click the roof surface you want to edit. Materials apply to one target at a time.
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<MaterialPicker
|
|
||||||
disabled={!materialTargetRole}
|
|
||||||
hideSideControl
|
|
||||||
nodeType="roof"
|
|
||||||
onChange={handleTargetedMaterialChange}
|
|
||||||
onSelectMaterialPreset={handleTargetedMaterialPresetChange}
|
|
||||||
selectedMaterialPreset={materialPickerValue.materialPreset}
|
|
||||||
value={materialPickerValue.material}
|
|
||||||
/>
|
|
||||||
</PanelSection>
|
|
||||||
</PanelWrapper>
|
</PanelWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import {
|
import {
|
||||||
type AnyNode,
|
type AnyNode,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
type MaterialSchema,
|
|
||||||
type RoofSegmentNode,
|
type RoofSegmentNode,
|
||||||
RoofSegmentNode as RoofSegmentNodeSchema,
|
RoofSegmentNode as RoofSegmentNodeSchema,
|
||||||
type RoofType,
|
type RoofType,
|
||||||
@@ -15,7 +14,6 @@ import { useCallback } from 'react'
|
|||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||||
import { MaterialPicker } from '../controls/material-picker'
|
|
||||||
import { PanelSection } from '../controls/panel-section'
|
import { PanelSection } from '../controls/panel-section'
|
||||||
import { SegmentedControl } from '../controls/segmented-control'
|
import { SegmentedControl } from '../controls/segmented-control'
|
||||||
import { SliderControl } from '../controls/slider-control'
|
import { SliderControl } from '../controls/slider-control'
|
||||||
@@ -52,20 +50,6 @@ export function RoofSegmentPanel() {
|
|||||||
[selectedId, updateNode],
|
[selectedId, updateNode],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleMaterialChange = useCallback(
|
|
||||||
(material: MaterialSchema) => {
|
|
||||||
handleUpdate({ material, materialPreset: undefined })
|
|
||||||
},
|
|
||||||
[handleUpdate],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleMaterialPresetChange = useCallback(
|
|
||||||
(materialPreset: string) => {
|
|
||||||
handleUpdate({ materialPreset, material: undefined })
|
|
||||||
},
|
|
||||||
[handleUpdate],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
}, [setSelection])
|
}, [setSelection])
|
||||||
@@ -322,15 +306,6 @@ export function RoofSegmentPanel() {
|
|||||||
/>
|
/>
|
||||||
</ActionGroup>
|
</ActionGroup>
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
<PanelSection title="Material">
|
|
||||||
<MaterialPicker
|
|
||||||
nodeType="roof-segment"
|
|
||||||
onChange={handleMaterialChange}
|
|
||||||
onSelectMaterialPreset={handleMaterialPresetChange}
|
|
||||||
selectedMaterialPreset={node.materialPreset}
|
|
||||||
value={node.material}
|
|
||||||
/>
|
|
||||||
</PanelSection>
|
|
||||||
</PanelWrapper>
|
</PanelWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { type AnyNode, type MaterialSchema, type SlabNode, useScene } from '@pascal-app/core'
|
import { type AnyNode, type SlabNode, useScene } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { Edit, Move, Plus, Trash2 } from 'lucide-react'
|
import { Edit, Move, Plus, Trash2 } from 'lucide-react'
|
||||||
import { useCallback, useEffect } from 'react'
|
import { useCallback, useEffect } from 'react'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||||
import { MaterialPicker } from '../controls/material-picker'
|
|
||||||
import { PanelSection } from '../controls/panel-section'
|
import { PanelSection } from '../controls/panel-section'
|
||||||
import { SliderControl } from '../controls/slider-control'
|
import { SliderControl } from '../controls/slider-control'
|
||||||
import { PanelWrapper } from './panel-wrapper'
|
import { PanelWrapper } from './panel-wrapper'
|
||||||
@@ -32,20 +31,6 @@ export function SlabPanel() {
|
|||||||
[selectedId, updateNode],
|
[selectedId, updateNode],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleMaterialPresetChange = useCallback(
|
|
||||||
(materialPreset: string) => {
|
|
||||||
handleUpdate({ materialPreset, material: undefined })
|
|
||||||
},
|
|
||||||
[handleUpdate],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleCustomMaterialChange = useCallback(
|
|
||||||
(material: MaterialSchema) => {
|
|
||||||
handleUpdate({ material, materialPreset: undefined })
|
|
||||||
},
|
|
||||||
[handleUpdate],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
setEditingHole(null)
|
setEditingHole(null)
|
||||||
@@ -257,20 +242,9 @@ export function SlabPanel() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
<PanelSection title="Material">
|
<ActionGroup>
|
||||||
<MaterialPicker
|
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||||
nodeType="slab"
|
</ActionGroup>
|
||||||
onChange={handleCustomMaterialChange}
|
|
||||||
onSelectMaterialPreset={handleMaterialPresetChange}
|
|
||||||
selectedMaterialPreset={node.materialPreset}
|
|
||||||
value={node.material}
|
|
||||||
/>
|
|
||||||
</PanelSection>
|
|
||||||
<PanelSection title="Actions">
|
|
||||||
<ActionGroup>
|
|
||||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
|
||||||
</ActionGroup>
|
|
||||||
</PanelSection>
|
|
||||||
</PanelWrapper>
|
</PanelWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,9 @@
|
|||||||
import {
|
import {
|
||||||
type AnyNode,
|
type AnyNode,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
getEffectiveStairSurfaceMaterial,
|
|
||||||
type LevelNode,
|
type LevelNode,
|
||||||
type MaterialSchema,
|
|
||||||
type StairNode,
|
type StairNode,
|
||||||
type StairRailingMode,
|
type StairRailingMode,
|
||||||
type StairSurfaceMaterialRole,
|
|
||||||
type StairSlabOpeningMode,
|
type StairSlabOpeningMode,
|
||||||
type StairTopLandingMode,
|
type StairTopLandingMode,
|
||||||
type StairType,
|
type StairType,
|
||||||
@@ -25,7 +22,6 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
|
|||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '../../tools/stair/stair-defaults'
|
import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '../../tools/stair/stair-defaults'
|
||||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||||
import { MaterialPicker } from '../controls/material-picker'
|
|
||||||
import { MetricControl } from '../controls/metric-control'
|
import { MetricControl } from '../controls/metric-control'
|
||||||
import { PanelSection } from '../controls/panel-section'
|
import { PanelSection } from '../controls/panel-section'
|
||||||
import { SegmentedControl } from '../controls/segmented-control'
|
import { SegmentedControl } from '../controls/segmented-control'
|
||||||
@@ -33,32 +29,6 @@ import { SliderControl } from '../controls/slider-control'
|
|||||||
import { ToggleControl } from '../controls/toggle-control'
|
import { ToggleControl } from '../controls/toggle-control'
|
||||||
import { PanelWrapper } from './panel-wrapper'
|
import { PanelWrapper } from './panel-wrapper'
|
||||||
|
|
||||||
function buildStairSurfaceMaterialPatch(
|
|
||||||
node: StairNode,
|
|
||||||
targetRole: StairSurfaceMaterialRole,
|
|
||||||
material: MaterialSchema | undefined,
|
|
||||||
materialPreset: string | undefined,
|
|
||||||
): Partial<StairNode> {
|
|
||||||
const nextSurfaceMaterial = { material, materialPreset }
|
|
||||||
const nextRailing =
|
|
||||||
targetRole === 'railing' ? nextSurfaceMaterial : getEffectiveStairSurfaceMaterial(node, 'railing')
|
|
||||||
const nextTread =
|
|
||||||
targetRole === 'tread' ? nextSurfaceMaterial : getEffectiveStairSurfaceMaterial(node, 'tread')
|
|
||||||
const nextSide =
|
|
||||||
targetRole === 'side' ? nextSurfaceMaterial : getEffectiveStairSurfaceMaterial(node, 'side')
|
|
||||||
|
|
||||||
return {
|
|
||||||
railingMaterial: nextRailing.material,
|
|
||||||
railingMaterialPreset: nextRailing.materialPreset,
|
|
||||||
treadMaterial: nextTread.material,
|
|
||||||
treadMaterialPreset: nextTread.materialPreset,
|
|
||||||
sideMaterial: nextSide.material,
|
|
||||||
sideMaterialPreset: nextSide.materialPreset,
|
|
||||||
material: undefined,
|
|
||||||
materialPreset: undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const RAILING_MODE_OPTIONS: { label: string; value: StairRailingMode }[] = [
|
const RAILING_MODE_OPTIONS: { label: string; value: StairRailingMode }[] = [
|
||||||
{ label: 'None', value: 'none' },
|
{ label: 'None', value: 'none' },
|
||||||
{ label: 'Left', value: 'left' },
|
{ label: 'Left', value: 'left' },
|
||||||
@@ -89,7 +59,6 @@ export function StairPanel() {
|
|||||||
const updateNode = useScene((s) => s.updateNode)
|
const updateNode = useScene((s) => s.updateNode)
|
||||||
const createNode = useScene((s) => s.createNode)
|
const createNode = useScene((s) => s.createNode)
|
||||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||||
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
|
|
||||||
|
|
||||||
const node = useScene((s) =>
|
const node = useScene((s) =>
|
||||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined,
|
selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined,
|
||||||
@@ -120,33 +89,6 @@ export function StairPanel() {
|
|||||||
[selectedId, updateNode],
|
[selectedId, updateNode],
|
||||||
)
|
)
|
||||||
|
|
||||||
const materialTargetRole =
|
|
||||||
selectedMaterialTarget &&
|
|
||||||
selectedMaterialTarget.nodeId === node?.id &&
|
|
||||||
(selectedMaterialTarget.role === 'railing' ||
|
|
||||||
selectedMaterialTarget.role === 'tread' ||
|
|
||||||
selectedMaterialTarget.role === 'side')
|
|
||||||
? selectedMaterialTarget.role
|
|
||||||
: null
|
|
||||||
const materialPickerValue =
|
|
||||||
node && materialTargetRole ? getEffectiveStairSurfaceMaterial(node, materialTargetRole) : {}
|
|
||||||
|
|
||||||
const handleTargetedMaterialChange = useCallback(
|
|
||||||
(material: MaterialSchema) => {
|
|
||||||
if (!node || !materialTargetRole) return
|
|
||||||
handleUpdate(buildStairSurfaceMaterialPatch(node, materialTargetRole, material, undefined))
|
|
||||||
},
|
|
||||||
[handleUpdate, materialTargetRole, node],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleTargetedMaterialPresetChange = useCallback(
|
|
||||||
(materialPreset: string) => {
|
|
||||||
if (!node || !materialTargetRole) return
|
|
||||||
handleUpdate(buildStairSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset))
|
|
||||||
},
|
|
||||||
[handleUpdate, materialTargetRole, node],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
}, [setSelection])
|
}, [setSelection])
|
||||||
@@ -578,22 +520,6 @@ export function StairPanel() {
|
|||||||
/>
|
/>
|
||||||
</ActionGroup>
|
</ActionGroup>
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
<PanelSection title="Material">
|
|
||||||
{!materialTargetRole ? (
|
|
||||||
<div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
|
|
||||||
Click the stair surface you want to edit. Materials apply to one target at a time.
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<MaterialPicker
|
|
||||||
disabled={!materialTargetRole}
|
|
||||||
hideSideControl
|
|
||||||
nodeType="stair"
|
|
||||||
onChange={handleTargetedMaterialChange}
|
|
||||||
onSelectMaterialPreset={handleTargetedMaterialPresetChange}
|
|
||||||
selectedMaterialPreset={materialPickerValue.materialPreset}
|
|
||||||
value={materialPickerValue.material}
|
|
||||||
/>
|
|
||||||
</PanelSection>
|
|
||||||
</PanelWrapper>
|
</PanelWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
type AnyNode,
|
type AnyNode,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
type AttachmentSide,
|
type AttachmentSide,
|
||||||
type MaterialSchema,
|
|
||||||
type StairSegmentNode,
|
type StairSegmentNode,
|
||||||
StairSegmentNode as StairSegmentNodeSchema,
|
StairSegmentNode as StairSegmentNodeSchema,
|
||||||
type StairSegmentType,
|
type StairSegmentType,
|
||||||
@@ -16,7 +15,6 @@ import { useCallback } from 'react'
|
|||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||||
import { MaterialPicker } from '../controls/material-picker'
|
|
||||||
import { PanelSection } from '../controls/panel-section'
|
import { PanelSection } from '../controls/panel-section'
|
||||||
import { SegmentedControl } from '../controls/segmented-control'
|
import { SegmentedControl } from '../controls/segmented-control'
|
||||||
import { SliderControl } from '../controls/slider-control'
|
import { SliderControl } from '../controls/slider-control'
|
||||||
@@ -61,20 +59,6 @@ export function StairSegmentPanel() {
|
|||||||
[selectedId, updateNode],
|
[selectedId, updateNode],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleMaterialChange = useCallback(
|
|
||||||
(material: MaterialSchema) => {
|
|
||||||
handleUpdate({ material, materialPreset: undefined })
|
|
||||||
},
|
|
||||||
[handleUpdate],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleMaterialPresetChange = useCallback(
|
|
||||||
(materialPreset: string) => {
|
|
||||||
handleUpdate({ materialPreset, material: undefined })
|
|
||||||
},
|
|
||||||
[handleUpdate],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
}, [setSelection])
|
}, [setSelection])
|
||||||
@@ -336,15 +320,6 @@ export function StairSegmentPanel() {
|
|||||||
/>
|
/>
|
||||||
</ActionGroup>
|
</ActionGroup>
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
<PanelSection title="Material">
|
|
||||||
<MaterialPicker
|
|
||||||
nodeType="stair-segment"
|
|
||||||
onChange={handleMaterialChange}
|
|
||||||
onSelectMaterialPreset={handleMaterialPresetChange}
|
|
||||||
selectedMaterialPreset={node.materialPreset}
|
|
||||||
value={node.material}
|
|
||||||
/>
|
|
||||||
</PanelSection>
|
|
||||||
</PanelWrapper>
|
</PanelWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,61 +3,29 @@
|
|||||||
import {
|
import {
|
||||||
type AnyNode,
|
type AnyNode,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
getEffectiveWallSurfaceMaterial,
|
|
||||||
getClampedWallCurveOffset,
|
getClampedWallCurveOffset,
|
||||||
getMaxWallCurveOffset,
|
getMaxWallCurveOffset,
|
||||||
getWallCurveLength,
|
getWallCurveLength,
|
||||||
getWallSurfaceMaterialSignature,
|
|
||||||
normalizeWallCurveOffset,
|
normalizeWallCurveOffset,
|
||||||
type MaterialSchema,
|
|
||||||
useScene,
|
useScene,
|
||||||
type WallSurfaceSide,
|
|
||||||
type WallNode,
|
type WallNode,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { Move, Spline } from 'lucide-react'
|
import { Move, Spline } from 'lucide-react'
|
||||||
import { useCallback, useMemo } from 'react'
|
import { useCallback } from 'react'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||||
import { MaterialPicker } from '../controls/material-picker'
|
|
||||||
import { PanelSection } from '../controls/panel-section'
|
import { PanelSection } from '../controls/panel-section'
|
||||||
import { SliderControl } from '../controls/slider-control'
|
import { SliderControl } from '../controls/slider-control'
|
||||||
import { PanelWrapper } from './panel-wrapper'
|
import { PanelWrapper } from './panel-wrapper'
|
||||||
|
|
||||||
function buildWallSurfaceMaterialPatch(
|
|
||||||
node: WallNode,
|
|
||||||
targetSide: WallSurfaceSide | null,
|
|
||||||
material: MaterialSchema | undefined,
|
|
||||||
materialPreset: string | undefined,
|
|
||||||
): Partial<WallNode> {
|
|
||||||
const nextSurfaceMaterial = { material, materialPreset }
|
|
||||||
const nextInterior =
|
|
||||||
targetSide === null || targetSide === 'interior'
|
|
||||||
? nextSurfaceMaterial
|
|
||||||
: getEffectiveWallSurfaceMaterial(node, 'interior')
|
|
||||||
const nextExterior =
|
|
||||||
targetSide === null || 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 WallPanel() {
|
export function WallPanel() {
|
||||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||||
const setSelection = useViewer((s) => s.setSelection)
|
const setSelection = useViewer((s) => s.setSelection)
|
||||||
const updateNode = useScene((s) => s.updateNode)
|
const updateNode = useScene((s) => s.updateNode)
|
||||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||||
const setCurvingWall = useEditor((s) => s.setCurvingWall)
|
const setCurvingWall = useEditor((s) => s.setCurvingWall)
|
||||||
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
|
|
||||||
|
|
||||||
const node = useScene((s) =>
|
const node = useScene((s) =>
|
||||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined,
|
selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined,
|
||||||
@@ -88,35 +56,6 @@ export function WallPanel() {
|
|||||||
[selectedId, updateNode],
|
[selectedId, updateNode],
|
||||||
)
|
)
|
||||||
|
|
||||||
const effectiveInteriorMaterial = useMemo(
|
|
||||||
() => (node ? getEffectiveWallSurfaceMaterial(node, 'interior') : {}),
|
|
||||||
[node],
|
|
||||||
)
|
|
||||||
const effectiveExteriorMaterial = useMemo(
|
|
||||||
() => (node ? getEffectiveWallSurfaceMaterial(node, 'exterior') : {}),
|
|
||||||
[node],
|
|
||||||
)
|
|
||||||
const surfaceMaterialsMatch = useMemo(
|
|
||||||
() =>
|
|
||||||
getWallSurfaceMaterialSignature(effectiveInteriorMaterial) ===
|
|
||||||
getWallSurfaceMaterialSignature(effectiveExteriorMaterial),
|
|
||||||
[effectiveExteriorMaterial, effectiveInteriorMaterial],
|
|
||||||
)
|
|
||||||
const materialTargetSide =
|
|
||||||
selectedMaterialTarget &&
|
|
||||||
selectedMaterialTarget.nodeId === node?.id &&
|
|
||||||
(selectedMaterialTarget.role === 'interior' || selectedMaterialTarget.role === 'exterior')
|
|
||||||
? selectedMaterialTarget.role
|
|
||||||
: null
|
|
||||||
const materialPickerValue =
|
|
||||||
materialTargetSide === 'interior'
|
|
||||||
? effectiveInteriorMaterial
|
|
||||||
: materialTargetSide === 'exterior'
|
|
||||||
? effectiveExteriorMaterial
|
|
||||||
: surfaceMaterialsMatch
|
|
||||||
? effectiveInteriorMaterial
|
|
||||||
: {}
|
|
||||||
|
|
||||||
const handleUpdateLength = useCallback(
|
const handleUpdateLength = useCallback(
|
||||||
(newLength: number) => {
|
(newLength: number) => {
|
||||||
if (!node || newLength <= 0) return
|
if (!node || newLength <= 0) return
|
||||||
@@ -140,22 +79,6 @@ export function WallPanel() {
|
|||||||
[node, handleUpdate],
|
[node, handleUpdate],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleMaterialPresetChange = useCallback(
|
|
||||||
(materialPreset: string) => {
|
|
||||||
if (!node || !materialTargetSide) return
|
|
||||||
handleUpdate(buildWallSurfaceMaterialPatch(node, materialTargetSide, undefined, materialPreset))
|
|
||||||
},
|
|
||||||
[handleUpdate, materialTargetSide, node],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleCustomMaterialChange = useCallback(
|
|
||||||
(material: MaterialSchema) => {
|
|
||||||
if (!node || !materialTargetSide) return
|
|
||||||
handleUpdate(buildWallSurfaceMaterialPatch(node, materialTargetSide, material, undefined))
|
|
||||||
},
|
|
||||||
[handleUpdate, materialTargetSide, node],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
}, [setSelection])
|
}, [setSelection])
|
||||||
@@ -237,23 +160,6 @@ export function WallPanel() {
|
|||||||
)}
|
)}
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
|
|
||||||
<PanelSection title="Material">
|
|
||||||
{!materialTargetSide ? (
|
|
||||||
<div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
|
|
||||||
Click the wall face you want to edit. Materials now apply to one side at a time.
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<MaterialPicker
|
|
||||||
disabled={!materialTargetSide}
|
|
||||||
hideSideControl
|
|
||||||
nodeType="wall"
|
|
||||||
onChange={handleCustomMaterialChange}
|
|
||||||
onSelectMaterialPreset={handleMaterialPresetChange}
|
|
||||||
selectedMaterialPreset={materialPickerValue.materialPreset}
|
|
||||||
value={materialPickerValue.material}
|
|
||||||
/>
|
|
||||||
</PanelSection>
|
|
||||||
|
|
||||||
<PanelSection title="Actions">
|
<PanelSection title="Actions">
|
||||||
<ActionGroup>
|
<ActionGroup>
|
||||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
type AnyNode,
|
type AnyNode,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
emitter,
|
emitter,
|
||||||
type MaterialSchema,
|
|
||||||
useScene,
|
useScene,
|
||||||
WindowNode,
|
WindowNode,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
@@ -15,7 +14,6 @@ import { usePresetsAdapter } from '../../../contexts/presets-context'
|
|||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||||
import { MaterialPicker } from '../controls/material-picker'
|
|
||||||
import { MetricControl } from '../controls/metric-control'
|
import { MetricControl } from '../controls/metric-control'
|
||||||
import { PanelSection } from '../controls/panel-section'
|
import { PanelSection } from '../controls/panel-section'
|
||||||
import { SliderControl } from '../controls/slider-control'
|
import { SliderControl } from '../controls/slider-control'
|
||||||
@@ -45,13 +43,6 @@ export function WindowPanel() {
|
|||||||
[selectedId, updateNode],
|
[selectedId, updateNode],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleMaterialChange = useCallback(
|
|
||||||
(material: MaterialSchema) => {
|
|
||||||
handleUpdate({ material })
|
|
||||||
},
|
|
||||||
[handleUpdate],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
}, [setSelection])
|
}, [setSelection])
|
||||||
@@ -431,9 +422,6 @@ export function WindowPanel() {
|
|||||||
/>
|
/>
|
||||||
</ActionGroup>
|
</ActionGroup>
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
<PanelSection title="Material">
|
|
||||||
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
|
|
||||||
</PanelSection>
|
|
||||||
</PanelWrapper>
|
</PanelWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,13 @@ export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => {
|
|||||||
if (isVersionPreviewMode) return
|
if (isVersionPreviewMode) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
useEditor.getState().setMode('delete')
|
useEditor.getState().setMode('delete')
|
||||||
|
} else if (e.key === 'p' && !e.metaKey && !e.ctrlKey) {
|
||||||
|
if (isVersionPreviewMode) return
|
||||||
|
e.preventDefault()
|
||||||
|
useEditor.getState().primeMaterialPaintFromSelection()
|
||||||
|
useEditor.getState().setPhase('structure')
|
||||||
|
useEditor.getState().setStructureLayer('elements')
|
||||||
|
useEditor.getState().setMode('material-paint')
|
||||||
} else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
|
} else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
|
||||||
if (isVersionPreviewMode) return
|
if (isVersionPreviewMode) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import {
|
||||||
|
type CeilingNode,
|
||||||
|
type FenceNode,
|
||||||
|
getCatalogMaterialById,
|
||||||
|
getEffectiveRoofSurfaceMaterial,
|
||||||
|
getEffectiveStairSurfaceMaterial,
|
||||||
|
getEffectiveWallSurfaceMaterial,
|
||||||
|
getLibraryMaterialIdFromRef,
|
||||||
|
type MaterialSchema,
|
||||||
|
type MaterialTarget,
|
||||||
|
type RoofNode,
|
||||||
|
type RoofSurfaceMaterialRole,
|
||||||
|
type SlabNode,
|
||||||
|
type StairNode,
|
||||||
|
type StairSurfaceMaterialRole,
|
||||||
|
type WallNode,
|
||||||
|
type WallSurfaceSide,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
|
||||||
|
export type PaintableMaterialTarget = Extract<
|
||||||
|
MaterialTarget,
|
||||||
|
'wall' | 'roof' | 'stair' | 'fence' | 'slab' | 'ceiling'
|
||||||
|
>
|
||||||
|
|
||||||
|
export type SingleSurfaceMaterialRole = 'surface'
|
||||||
|
|
||||||
|
export type ActivePaintMaterial = {
|
||||||
|
material?: MaterialSchema
|
||||||
|
materialPreset?: string
|
||||||
|
sourceTarget: PaintableMaterialTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasActivePaintMaterial(
|
||||||
|
material: ActivePaintMaterial | null | undefined,
|
||||||
|
): material is ActivePaintMaterial {
|
||||||
|
return Boolean(
|
||||||
|
material && (material.material !== undefined || material.materialPreset !== undefined),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCatalogEntryForActivePaintMaterial(material: ActivePaintMaterial | null | undefined) {
|
||||||
|
const catalogId =
|
||||||
|
getLibraryMaterialIdFromRef(material?.materialPreset) ?? material?.material?.id ?? undefined
|
||||||
|
|
||||||
|
return getCatalogMaterialById(catalogId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActivePaintMaterialLabel(material: ActivePaintMaterial | null | undefined) {
|
||||||
|
return getCatalogEntryForActivePaintMaterial(material)?.label ?? 'Custom'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWallSurfaceMaterialPatch(
|
||||||
|
node: WallNode,
|
||||||
|
targetSide: WallSurfaceSide,
|
||||||
|
material: MaterialSchema | undefined,
|
||||||
|
materialPreset: string | undefined,
|
||||||
|
): Partial<WallNode> {
|
||||||
|
const nextSurfaceMaterial = { material, materialPreset }
|
||||||
|
const nextInterior =
|
||||||
|
targetSide === 'interior'
|
||||||
|
? nextSurfaceMaterial
|
||||||
|
: getEffectiveWallSurfaceMaterial(node, 'interior')
|
||||||
|
const nextExterior =
|
||||||
|
targetSide === 'exterior'
|
||||||
|
? nextSurfaceMaterial
|
||||||
|
: getEffectiveWallSurfaceMaterial(node, 'exterior')
|
||||||
|
|
||||||
|
return {
|
||||||
|
interiorMaterial: nextInterior.material,
|
||||||
|
interiorMaterialPreset: nextInterior.materialPreset,
|
||||||
|
exteriorMaterial: nextExterior.material,
|
||||||
|
exteriorMaterialPreset: nextExterior.materialPreset,
|
||||||
|
material: undefined,
|
||||||
|
materialPreset: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRoofSurfaceMaterialPatch(
|
||||||
|
node: RoofNode,
|
||||||
|
targetRole: RoofSurfaceMaterialRole,
|
||||||
|
material: MaterialSchema | undefined,
|
||||||
|
materialPreset: string | undefined,
|
||||||
|
): Partial<RoofNode> {
|
||||||
|
const nextSurfaceMaterial = { material, materialPreset }
|
||||||
|
const nextTop =
|
||||||
|
targetRole === 'top' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'top')
|
||||||
|
const nextEdge =
|
||||||
|
targetRole === 'edge' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'edge')
|
||||||
|
const nextWall =
|
||||||
|
targetRole === 'wall' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'wall')
|
||||||
|
|
||||||
|
return {
|
||||||
|
topMaterial: nextTop.material,
|
||||||
|
topMaterialPreset: nextTop.materialPreset,
|
||||||
|
edgeMaterial: nextEdge.material,
|
||||||
|
edgeMaterialPreset: nextEdge.materialPreset,
|
||||||
|
wallMaterial: nextWall.material,
|
||||||
|
wallMaterialPreset: nextWall.materialPreset,
|
||||||
|
material: undefined,
|
||||||
|
materialPreset: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildStairSurfaceMaterialPatch(
|
||||||
|
node: StairNode,
|
||||||
|
targetRole: StairSurfaceMaterialRole,
|
||||||
|
material: MaterialSchema | undefined,
|
||||||
|
materialPreset: string | undefined,
|
||||||
|
): Partial<StairNode> {
|
||||||
|
const nextSurfaceMaterial = { material, materialPreset }
|
||||||
|
const nextRailing =
|
||||||
|
targetRole === 'railing'
|
||||||
|
? nextSurfaceMaterial
|
||||||
|
: getEffectiveStairSurfaceMaterial(node, 'railing')
|
||||||
|
const nextTread =
|
||||||
|
targetRole === 'tread' ? nextSurfaceMaterial : getEffectiveStairSurfaceMaterial(node, 'tread')
|
||||||
|
const nextSide =
|
||||||
|
targetRole === 'side' ? nextSurfaceMaterial : getEffectiveStairSurfaceMaterial(node, 'side')
|
||||||
|
|
||||||
|
return {
|
||||||
|
railingMaterial: nextRailing.material,
|
||||||
|
railingMaterialPreset: nextRailing.materialPreset,
|
||||||
|
treadMaterial: nextTread.material,
|
||||||
|
treadMaterialPreset: nextTread.materialPreset,
|
||||||
|
sideMaterial: nextSide.material,
|
||||||
|
sideMaterialPreset: nextSide.materialPreset,
|
||||||
|
material: undefined,
|
||||||
|
materialPreset: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSingleSurfaceMaterialPatch<TNode extends FenceNode | SlabNode | CeilingNode>(
|
||||||
|
material: MaterialSchema | undefined,
|
||||||
|
materialPreset: string | undefined,
|
||||||
|
): Partial<TNode> {
|
||||||
|
return {
|
||||||
|
material,
|
||||||
|
materialPreset,
|
||||||
|
} as Partial<TNode>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveActivePaintMaterialFromSelection(params: {
|
||||||
|
nodes: Record<string, any>
|
||||||
|
selectedId: string | null
|
||||||
|
selectedMaterialTarget: {
|
||||||
|
nodeId: string
|
||||||
|
role:
|
||||||
|
| WallSurfaceSide
|
||||||
|
| StairSurfaceMaterialRole
|
||||||
|
| RoofSurfaceMaterialRole
|
||||||
|
| SingleSurfaceMaterialRole
|
||||||
|
} | null
|
||||||
|
}): ActivePaintMaterial | null {
|
||||||
|
const { nodes, selectedId, selectedMaterialTarget } = params
|
||||||
|
if (!selectedId || !selectedMaterialTarget || selectedMaterialTarget.nodeId !== selectedId)
|
||||||
|
return null
|
||||||
|
|
||||||
|
const selectedNode = nodes[selectedId]
|
||||||
|
if (!selectedNode) return null
|
||||||
|
|
||||||
|
if (
|
||||||
|
selectedNode.type === 'wall' &&
|
||||||
|
(selectedMaterialTarget.role === 'interior' || selectedMaterialTarget.role === 'exterior')
|
||||||
|
) {
|
||||||
|
const surface = getEffectiveWallSurfaceMaterial(selectedNode, selectedMaterialTarget.role)
|
||||||
|
return hasActivePaintMaterial({
|
||||||
|
material: surface.material,
|
||||||
|
materialPreset: surface.materialPreset,
|
||||||
|
sourceTarget: 'wall',
|
||||||
|
})
|
||||||
|
? {
|
||||||
|
material: surface.material,
|
||||||
|
materialPreset: surface.materialPreset,
|
||||||
|
sourceTarget: 'wall',
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
selectedNode.type === 'roof' &&
|
||||||
|
(selectedMaterialTarget.role === 'top' ||
|
||||||
|
selectedMaterialTarget.role === 'edge' ||
|
||||||
|
selectedMaterialTarget.role === 'wall')
|
||||||
|
) {
|
||||||
|
const surface = getEffectiveRoofSurfaceMaterial(selectedNode, selectedMaterialTarget.role)
|
||||||
|
return hasActivePaintMaterial({
|
||||||
|
material: surface.material,
|
||||||
|
materialPreset: surface.materialPreset,
|
||||||
|
sourceTarget: 'roof',
|
||||||
|
})
|
||||||
|
? {
|
||||||
|
material: surface.material,
|
||||||
|
materialPreset: surface.materialPreset,
|
||||||
|
sourceTarget: 'roof',
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
selectedNode.type === 'stair' &&
|
||||||
|
(selectedMaterialTarget.role === 'railing' ||
|
||||||
|
selectedMaterialTarget.role === 'tread' ||
|
||||||
|
selectedMaterialTarget.role === 'side')
|
||||||
|
) {
|
||||||
|
const surface = getEffectiveStairSurfaceMaterial(selectedNode, selectedMaterialTarget.role)
|
||||||
|
return hasActivePaintMaterial({
|
||||||
|
material: surface.material,
|
||||||
|
materialPreset: surface.materialPreset,
|
||||||
|
sourceTarget: 'stair',
|
||||||
|
})
|
||||||
|
? {
|
||||||
|
material: surface.material,
|
||||||
|
materialPreset: surface.materialPreset,
|
||||||
|
sourceTarget: 'stair',
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(selectedNode.type === 'fence' ||
|
||||||
|
selectedNode.type === 'slab' ||
|
||||||
|
selectedNode.type === 'ceiling') &&
|
||||||
|
selectedMaterialTarget.role === 'surface'
|
||||||
|
) {
|
||||||
|
const target = selectedNode.type
|
||||||
|
return hasActivePaintMaterial({
|
||||||
|
material: selectedNode.material,
|
||||||
|
materialPreset: selectedNode.materialPreset,
|
||||||
|
sourceTarget: target,
|
||||||
|
})
|
||||||
|
? {
|
||||||
|
material: selectedNode.material,
|
||||||
|
materialPreset: selectedNode.materialPreset,
|
||||||
|
sourceTarget: target,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePaintTargetFromSelection(params: {
|
||||||
|
nodes: Record<string, any>
|
||||||
|
selectedId: string | null
|
||||||
|
}): PaintableMaterialTarget | null {
|
||||||
|
const { nodes, selectedId } = params
|
||||||
|
if (!selectedId) return null
|
||||||
|
|
||||||
|
const selectedNode = nodes[selectedId]
|
||||||
|
if (!selectedNode) return null
|
||||||
|
|
||||||
|
if (selectedNode.type === 'wall') {
|
||||||
|
return 'wall'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedNode.type === 'roof' || selectedNode.type === 'roof-segment') {
|
||||||
|
return 'roof'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedNode.type === 'stair' || selectedNode.type === 'stair-segment') {
|
||||||
|
return 'stair'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedNode.type === 'fence') {
|
||||||
|
return 'fence'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedNode.type === 'slab') {
|
||||||
|
return 'slab'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedNode.type === 'ceiling') {
|
||||||
|
return 'ceiling'
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -9,14 +9,14 @@ import {
|
|||||||
type FenceNode,
|
type FenceNode,
|
||||||
type ItemNode,
|
type ItemNode,
|
||||||
type LevelNode,
|
type LevelNode,
|
||||||
type RoofSurfaceMaterialRole,
|
|
||||||
type RoofNode,
|
type RoofNode,
|
||||||
type RoofSegmentNode,
|
type RoofSegmentNode,
|
||||||
|
type RoofSurfaceMaterialRole,
|
||||||
type SlabNode,
|
type SlabNode,
|
||||||
type Space,
|
type Space,
|
||||||
type StairSurfaceMaterialRole,
|
|
||||||
type StairNode,
|
type StairNode,
|
||||||
type StairSegmentNode,
|
type StairSegmentNode,
|
||||||
|
type StairSurfaceMaterialRole,
|
||||||
useScene,
|
useScene,
|
||||||
type WallNode,
|
type WallNode,
|
||||||
type WallSurfaceSide,
|
type WallSurfaceSide,
|
||||||
@@ -26,6 +26,13 @@ import { useViewer } from '@pascal-app/viewer'
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { persist } from 'zustand/middleware'
|
import { persist } from 'zustand/middleware'
|
||||||
import { getDefaultCatalogItem } from '../components/ui/item-catalog/catalog-items'
|
import { getDefaultCatalogItem } from '../components/ui/item-catalog/catalog-items'
|
||||||
|
import {
|
||||||
|
type ActivePaintMaterial,
|
||||||
|
type PaintableMaterialTarget,
|
||||||
|
resolveActivePaintMaterialFromSelection,
|
||||||
|
resolvePaintTargetFromSelection,
|
||||||
|
type SingleSurfaceMaterialRole,
|
||||||
|
} from '../lib/material-paint'
|
||||||
|
|
||||||
const DEFAULT_ACTIVE_SIDEBAR_PANEL = 'site'
|
const DEFAULT_ACTIVE_SIDEBAR_PANEL = 'site'
|
||||||
const DEFAULT_FLOORPLAN_PANE_RATIO = 0.5
|
const DEFAULT_FLOORPLAN_PANE_RATIO = 0.5
|
||||||
@@ -37,7 +44,7 @@ export type SplitOrientation = 'horizontal' | 'vertical'
|
|||||||
|
|
||||||
export type Phase = 'site' | 'structure' | 'furnish'
|
export type Phase = 'site' | 'structure' | 'furnish'
|
||||||
|
|
||||||
export type Mode = 'select' | 'edit' | 'delete' | 'build'
|
export type Mode = 'select' | 'edit' | 'delete' | 'build' | 'material-paint'
|
||||||
|
|
||||||
// Structure mode tools (building elements)
|
// Structure mode tools (building elements)
|
||||||
export type StructureTool =
|
export type StructureTool =
|
||||||
@@ -89,13 +96,19 @@ export type MovingFenceEndpoint = {
|
|||||||
endpoint: 'start' | 'end'
|
endpoint: 'start' | 'end'
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MaterialTargetRole = WallSurfaceSide | StairSurfaceMaterialRole | RoofSurfaceMaterialRole
|
export type MaterialTargetRole = WallSurfaceSide | StairSurfaceMaterialRole | RoofSurfaceMaterialRole | SingleSurfaceMaterialRole
|
||||||
|
|
||||||
export type SelectedMaterialTarget = {
|
export type SelectedMaterialTarget = {
|
||||||
nodeId: AnyNodeId
|
nodeId: AnyNodeId
|
||||||
role: MaterialTargetRole
|
role: MaterialTargetRole
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MaterialPaintSelectionSnapshot = {
|
||||||
|
selectedId: string | null
|
||||||
|
activePaintTarget: PaintableMaterialTarget
|
||||||
|
activePaintMaterial: ActivePaintMaterial | null
|
||||||
|
}
|
||||||
|
|
||||||
type EditorState = {
|
type EditorState = {
|
||||||
phase: Phase
|
phase: Phase
|
||||||
setPhase: (phase: Phase) => void
|
setPhase: (phase: Phase) => void
|
||||||
@@ -149,6 +162,15 @@ type EditorState = {
|
|||||||
setCurvingFence: (fence: FenceNode | null) => void
|
setCurvingFence: (fence: FenceNode | null) => void
|
||||||
selectedMaterialTarget: SelectedMaterialTarget | null
|
selectedMaterialTarget: SelectedMaterialTarget | null
|
||||||
setSelectedMaterialTarget: (target: SelectedMaterialTarget | null) => void
|
setSelectedMaterialTarget: (target: SelectedMaterialTarget | null) => void
|
||||||
|
activePaintMaterial: ActivePaintMaterial | null
|
||||||
|
setActivePaintMaterial: (material: ActivePaintMaterial | null) => void
|
||||||
|
activePaintTarget: PaintableMaterialTarget
|
||||||
|
setActivePaintTarget: (target: PaintableMaterialTarget) => void
|
||||||
|
primeMaterialPaintFromSelection: () => MaterialPaintSelectionSnapshot
|
||||||
|
hoveredPaintTarget: PaintableMaterialTarget | null
|
||||||
|
setHoveredPaintTarget: (target: PaintableMaterialTarget | null) => void
|
||||||
|
isPaintPanelOpen: boolean
|
||||||
|
setPaintPanelOpen: (open: boolean) => void
|
||||||
selectedReferenceId: string | null
|
selectedReferenceId: string | null
|
||||||
setSelectedReferenceId: (id: string | null) => void
|
setSelectedReferenceId: (id: string | null) => void
|
||||||
// Space detection for cutaway mode
|
// Space detection for cutaway mode
|
||||||
@@ -228,7 +250,7 @@ function normalizeModeForPhase(phase: Phase, mode: Mode | undefined): Mode {
|
|||||||
return 'select'
|
return 'select'
|
||||||
}
|
}
|
||||||
|
|
||||||
return mode === 'build' || mode === 'delete' ? mode : 'select'
|
return mode === 'build' || mode === 'delete' || mode === 'material-paint' ? mode : 'select'
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeFloorplanPaneRatio(value: unknown): number {
|
function normalizeFloorplanPaneRatio(value: unknown): number {
|
||||||
@@ -466,6 +488,8 @@ const useEditor = create<EditorState>()(
|
|||||||
const category = get().catalogCategory ?? 'furniture'
|
const category = get().catalogCategory ?? 'furniture'
|
||||||
set({ selectedItem: getDefaultSelectedItemForCategory(category) })
|
set({ selectedItem: getDefaultSelectedItemForCategory(category) })
|
||||||
}
|
}
|
||||||
|
} else if (mode === 'material-paint') {
|
||||||
|
get().primeMaterialPaintFromSelection()
|
||||||
}
|
}
|
||||||
// When leaving build mode, clear tool
|
// When leaving build mode, clear tool
|
||||||
else if (tool) {
|
else if (tool) {
|
||||||
@@ -530,6 +554,47 @@ const useEditor = create<EditorState>()(
|
|||||||
setCurvingFence: (fence) => set({ curvingFence: fence }),
|
setCurvingFence: (fence) => set({ curvingFence: fence }),
|
||||||
selectedMaterialTarget: null,
|
selectedMaterialTarget: null,
|
||||||
setSelectedMaterialTarget: (target) => set({ selectedMaterialTarget: target }),
|
setSelectedMaterialTarget: (target) => set({ selectedMaterialTarget: target }),
|
||||||
|
activePaintMaterial: null,
|
||||||
|
setActivePaintMaterial: (material) => set({ activePaintMaterial: material }),
|
||||||
|
activePaintTarget: 'wall',
|
||||||
|
setActivePaintTarget: (target) =>
|
||||||
|
set((state) =>
|
||||||
|
state.activePaintTarget === target ? state : { activePaintTarget: target },
|
||||||
|
),
|
||||||
|
primeMaterialPaintFromSelection: () => {
|
||||||
|
const selectedId =
|
||||||
|
useViewer.getState().selection.selectedIds.length === 1
|
||||||
|
? (useViewer.getState().selection.selectedIds[0] ?? null)
|
||||||
|
: null
|
||||||
|
const activePaintTarget =
|
||||||
|
resolvePaintTargetFromSelection({
|
||||||
|
nodes: useScene.getState().nodes,
|
||||||
|
selectedId,
|
||||||
|
}) ?? get().activePaintTarget
|
||||||
|
const activePaintMaterial = resolveActivePaintMaterialFromSelection({
|
||||||
|
nodes: useScene.getState().nodes,
|
||||||
|
selectedId,
|
||||||
|
selectedMaterialTarget: get().selectedMaterialTarget,
|
||||||
|
})
|
||||||
|
|
||||||
|
set({
|
||||||
|
activePaintTarget,
|
||||||
|
...(activePaintMaterial ? { activePaintMaterial } : {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedId,
|
||||||
|
activePaintTarget,
|
||||||
|
activePaintMaterial: activePaintMaterial ?? get().activePaintMaterial,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
hoveredPaintTarget: null,
|
||||||
|
setHoveredPaintTarget: (target) =>
|
||||||
|
set((state) =>
|
||||||
|
state.hoveredPaintTarget === target ? state : { hoveredPaintTarget: target },
|
||||||
|
),
|
||||||
|
isPaintPanelOpen: false,
|
||||||
|
setPaintPanelOpen: (open) => set({ isPaintPanelOpen: open }),
|
||||||
selectedReferenceId: null,
|
selectedReferenceId: null,
|
||||||
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
|
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
|
||||||
spaces: {},
|
spaces: {},
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { type CeilingNode, getMaterialPresetByRef, resolveMaterial, useRegistry } from '@pascal-app/core'
|
import {
|
||||||
|
type CeilingNode,
|
||||||
|
getMaterialPresetByRef,
|
||||||
|
resolveMaterial,
|
||||||
|
useRegistry,
|
||||||
|
} from '@pascal-app/core'
|
||||||
import { useMemo, useRef } from 'react'
|
import { useMemo, useRef } from 'react'
|
||||||
import { float, mix, positionWorld, smoothstep } from 'three/tsl'
|
import { float, mix, positionWorld, smoothstep } from 'three/tsl'
|
||||||
import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu'
|
import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu'
|
||||||
@@ -32,6 +37,18 @@ function createCeilingMaterials(color = '#999999') {
|
|||||||
return { topMaterial, bottomMaterial }
|
return { topMaterial, bottomMaterial }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ceilingMaterialCache = new Map<string, ReturnType<typeof createCeilingMaterials>>()
|
||||||
|
|
||||||
|
function getCeilingMaterials(color = '#999999') {
|
||||||
|
const cacheKey = color
|
||||||
|
const cached = ceilingMaterialCache.get(cacheKey)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
const materials = createCeilingMaterials(color)
|
||||||
|
ceilingMaterialCache.set(cacheKey, materials)
|
||||||
|
return materials
|
||||||
|
}
|
||||||
|
|
||||||
export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
|
export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
|
||||||
const ref = useRef<Mesh>(null!)
|
const ref = useRef<Mesh>(null!)
|
||||||
|
|
||||||
@@ -42,8 +59,14 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
|
|||||||
const preset = getMaterialPresetByRef(node.materialPreset)
|
const preset = getMaterialPresetByRef(node.materialPreset)
|
||||||
const props = preset?.mapProperties ?? resolveMaterial(node.material)
|
const props = preset?.mapProperties ?? resolveMaterial(node.material)
|
||||||
const color = props.color || '#999999'
|
const color = props.color || '#999999'
|
||||||
return createCeilingMaterials(color)
|
return getCeilingMaterials(color)
|
||||||
}, [node.materialPreset, node.material, node.material?.preset, node.material?.properties, node.material?.texture])
|
}, [
|
||||||
|
node.materialPreset,
|
||||||
|
node.material,
|
||||||
|
node.material?.preset,
|
||||||
|
node.material?.properties,
|
||||||
|
node.material?.texture,
|
||||||
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<mesh material={materials.bottomMaterial} ref={ref}>
|
<mesh material={materials.bottomMaterial} ref={ref}>
|
||||||
|
|||||||
@@ -31,7 +31,14 @@ export const FenceRenderer = ({ node }: { node: FenceNode }) => {
|
|||||||
}, [node.id])
|
}, [node.id])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<mesh castShadow material={material} receiveShadow ref={ref} visible={node.visible} {...handlers}>
|
<mesh
|
||||||
|
castShadow
|
||||||
|
material={material}
|
||||||
|
receiveShadow
|
||||||
|
ref={ref}
|
||||||
|
visible={node.visible}
|
||||||
|
{...handlers}
|
||||||
|
>
|
||||||
<boxGeometry args={[0, 0, 0]} />
|
<boxGeometry args={[0, 0, 0]} />
|
||||||
</mesh>
|
</mesh>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { type AnyNodeId, type RoofNode, type RoofSegmentNode, useRegistry, useScene } from '@pascal-app/core'
|
import {
|
||||||
|
type AnyNodeId,
|
||||||
|
type RoofNode,
|
||||||
|
type RoofSegmentNode,
|
||||||
|
useRegistry,
|
||||||
|
useScene,
|
||||||
|
} from '@pascal-app/core'
|
||||||
import { useEffect, useMemo, useRef } from 'react'
|
import { useEffect, useMemo, useRef } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||||
@@ -14,8 +20,9 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
|
|||||||
|
|
||||||
const handlers = useNodeEvents(node, 'roof-segment')
|
const handlers = useNodeEvents(node, 'roof-segment')
|
||||||
const debugColors = useViewer((s) => s.debugColors)
|
const debugColors = useViewer((s) => s.debugColors)
|
||||||
const parentNode =
|
const parentNode = node.parentId
|
||||||
node.parentId ? (nodes[node.parentId as AnyNodeId] as RoofNode | undefined) : undefined
|
? (nodes[node.parentId as AnyNodeId] as RoofNode | undefined)
|
||||||
|
: undefined
|
||||||
const placeholderGeometry = useMemo(() => {
|
const placeholderGeometry = useMemo(() => {
|
||||||
const geometry = new THREE.BufferGeometry()
|
const geometry = new THREE.BufferGeometry()
|
||||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
|
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
|
||||||
@@ -32,24 +39,7 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return parentNode ? getRoofMaterialArray(parentNode) : null
|
return parentNode ? getRoofMaterialArray(parentNode) : null
|
||||||
}, [
|
}, [node, parentNode])
|
||||||
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,
|
|
||||||
parentNode?.topMaterial,
|
|
||||||
parentNode?.topMaterialPreset,
|
|
||||||
parentNode?.edgeMaterial,
|
|
||||||
parentNode?.edgeMaterialPreset,
|
|
||||||
parentNode?.wallMaterial,
|
|
||||||
parentNode?.wallMaterialPreset,
|
|
||||||
])
|
|
||||||
|
|
||||||
const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials
|
const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials
|
||||||
|
|
||||||
|
|||||||
@@ -24,22 +24,7 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => {
|
|||||||
return geometry
|
return geometry
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const customMaterial = useMemo(
|
const customMaterial = useMemo(() => getRoofMaterialArray(node), [node])
|
||||||
() => getRoofMaterialArray(node),
|
|
||||||
[
|
|
||||||
node.materialPreset,
|
|
||||||
node.material,
|
|
||||||
node.material?.preset,
|
|
||||||
node.material?.properties,
|
|
||||||
node.material?.texture,
|
|
||||||
node.topMaterial,
|
|
||||||
node.topMaterialPreset,
|
|
||||||
node.edgeMaterial,
|
|
||||||
node.edgeMaterialPreset,
|
|
||||||
node.wallMaterial,
|
|
||||||
node.wallMaterialPreset,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials
|
const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { getMaterialPresetByRef, type SlabNode, useRegistry } from '@pascal-app/core'
|
import { getMaterialPresetByRef, type SlabNode, useRegistry } from '@pascal-app/core'
|
||||||
import { useEffect, useMemo, useRef } from 'react'
|
import { useMemo, useRef } from 'react'
|
||||||
import * as THREE from 'three'
|
|
||||||
import type { Mesh } from 'three'
|
import type { Mesh } from 'three'
|
||||||
|
import * as THREE from 'three'
|
||||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||||
import {
|
import {
|
||||||
applyMaterialPresetToMaterials,
|
applyMaterialPresetToMaterials,
|
||||||
@@ -9,6 +9,42 @@ import {
|
|||||||
DEFAULT_SLAB_MATERIAL,
|
DEFAULT_SLAB_MATERIAL,
|
||||||
} from '../../../lib/materials'
|
} from '../../../lib/materials'
|
||||||
|
|
||||||
|
const slabMaterialCache = new Map<string, THREE.MeshStandardMaterial>()
|
||||||
|
|
||||||
|
function getSlabMaterial(
|
||||||
|
cacheKey: string,
|
||||||
|
params: { material?: SlabNode['material']; materialPreset?: string },
|
||||||
|
) {
|
||||||
|
const cached = slabMaterialCache.get(cacheKey)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
const preset = getMaterialPresetByRef(params.materialPreset)
|
||||||
|
const slabMaterial = preset
|
||||||
|
? new THREE.MeshStandardMaterial()
|
||||||
|
: params.material
|
||||||
|
? createMaterial(params.material).clone()
|
||||||
|
: DEFAULT_SLAB_MATERIAL.clone()
|
||||||
|
|
||||||
|
if (preset) {
|
||||||
|
// Apply the preset to the slab-owned material so async texture loads update
|
||||||
|
// the instance we actually render after refresh as well.
|
||||||
|
applyMaterialPresetToMaterials(slabMaterial, preset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slabs participate in the WebGPU MRT scene pass. Keeping them opaque avoids
|
||||||
|
// pipeline variants that can fail when geometry is regenerated while a
|
||||||
|
// transparent/custom material is attached.
|
||||||
|
slabMaterial.transparent = false
|
||||||
|
slabMaterial.opacity = 1
|
||||||
|
slabMaterial.alphaMap = null
|
||||||
|
slabMaterial.side = THREE.DoubleSide
|
||||||
|
slabMaterial.depthWrite = true
|
||||||
|
slabMaterial.needsUpdate = true
|
||||||
|
|
||||||
|
slabMaterialCache.set(cacheKey, slabMaterial)
|
||||||
|
return slabMaterial
|
||||||
|
}
|
||||||
|
|
||||||
export const SlabRenderer = ({ node }: { node: SlabNode }) => {
|
export const SlabRenderer = ({ node }: { node: SlabNode }) => {
|
||||||
const ref = useRef<Mesh>(null!)
|
const ref = useRef<Mesh>(null!)
|
||||||
|
|
||||||
@@ -17,30 +53,17 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => {
|
|||||||
const handlers = useNodeEvents(node, 'slab')
|
const handlers = useNodeEvents(node, 'slab')
|
||||||
|
|
||||||
const material = useMemo(() => {
|
const material = useMemo(() => {
|
||||||
const preset = getMaterialPresetByRef(node.materialPreset)
|
const resolvedMaterial = node.material
|
||||||
const slabMaterial = preset
|
const resolvedMaterialPreset = node.materialPreset
|
||||||
? new THREE.MeshStandardMaterial()
|
const cacheKey = JSON.stringify({
|
||||||
: node.material
|
material: resolvedMaterial ?? null,
|
||||||
? createMaterial(node.material).clone()
|
materialPreset: resolvedMaterialPreset ?? null,
|
||||||
: DEFAULT_SLAB_MATERIAL.clone()
|
})
|
||||||
|
|
||||||
if (preset) {
|
return getSlabMaterial(cacheKey, {
|
||||||
// Apply the preset to the slab-owned material so async texture loads update
|
material: resolvedMaterial,
|
||||||
// the instance we actually render after refresh as well.
|
materialPreset: resolvedMaterialPreset,
|
||||||
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
|
|
||||||
|
|
||||||
return slabMaterial
|
|
||||||
}, [
|
}, [
|
||||||
node.material,
|
node.material,
|
||||||
node.material?.preset,
|
node.material?.preset,
|
||||||
@@ -49,12 +72,6 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => {
|
|||||||
node.materialPreset,
|
node.materialPreset,
|
||||||
])
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
material.dispose()
|
|
||||||
}
|
|
||||||
}, [material])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<mesh
|
<mesh
|
||||||
castShadow
|
castShadow
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { type AnyNodeId, type StairNode, type StairSegmentNode, useRegistry, useScene } from '@pascal-app/core'
|
import {
|
||||||
|
type AnyNodeId,
|
||||||
|
type StairNode,
|
||||||
|
type StairSegmentNode,
|
||||||
|
useRegistry,
|
||||||
|
useScene,
|
||||||
|
} from '@pascal-app/core'
|
||||||
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||||
@@ -15,29 +21,13 @@ export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => {
|
|||||||
}, [node.id])
|
}, [node.id])
|
||||||
|
|
||||||
const handlers = useNodeEvents(node, 'stair-segment')
|
const handlers = useNodeEvents(node, 'stair-segment')
|
||||||
const parentNode =
|
const parentNode = node.parentId
|
||||||
node.parentId ? (nodes[node.parentId as AnyNodeId] as StairNode | undefined) : undefined
|
? (nodes[node.parentId as AnyNodeId] as StairNode | undefined)
|
||||||
|
: undefined
|
||||||
const material = useMemo(() => {
|
const material = useMemo(
|
||||||
return getStraightStairSegmentBodyMaterials(node, parentNode)
|
() => getStraightStairSegmentBodyMaterials(node, parentNode),
|
||||||
}, [
|
[node, parentNode],
|
||||||
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,
|
|
||||||
parentNode?.railingMaterialPreset,
|
|
||||||
parentNode?.railingMaterial,
|
|
||||||
parentNode?.sideMaterialPreset,
|
|
||||||
parentNode?.sideMaterial,
|
|
||||||
parentNode?.treadMaterialPreset,
|
|
||||||
parentNode?.treadMaterial,
|
|
||||||
])
|
|
||||||
|
|
||||||
const placeholderGeometry = useMemo(() => {
|
const placeholderGeometry = useMemo(() => {
|
||||||
const geometry = new THREE.BufferGeometry()
|
const geometry = new THREE.BufferGeometry()
|
||||||
|
|||||||
@@ -8,10 +8,14 @@ import {
|
|||||||
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||||
import { createMaterial, createMaterialFromPresetRef, DEFAULT_STAIR_MATERIAL } from '../../../lib/materials'
|
|
||||||
import {
|
import {
|
||||||
getStairRailingMaterial,
|
createMaterial,
|
||||||
|
createMaterialFromPresetRef,
|
||||||
|
DEFAULT_STAIR_MATERIAL,
|
||||||
|
} from '../../../lib/materials'
|
||||||
|
import {
|
||||||
getStairBodyMaterials,
|
getStairBodyMaterials,
|
||||||
|
getStairRailingMaterial,
|
||||||
type StairBodyMaterials,
|
type StairBodyMaterials,
|
||||||
} from '../../../systems/stair/stair-materials'
|
} from '../../../systems/stair/stair-materials'
|
||||||
import { NodeRenderer } from '../node-renderer'
|
import { NodeRenderer } from '../node-renderer'
|
||||||
@@ -72,33 +76,9 @@ export const StairRenderer = ({ node }: { node: StairNode }) => {
|
|||||||
node.material?.texture,
|
node.material?.texture,
|
||||||
])
|
])
|
||||||
|
|
||||||
const straightBodyMaterials = useMemo(
|
const straightBodyMaterials = useMemo(() => getStairBodyMaterials(node), [node])
|
||||||
() => getStairBodyMaterials(node),
|
|
||||||
[
|
|
||||||
node.material,
|
|
||||||
node.materialPreset,
|
|
||||||
node.railingMaterial,
|
|
||||||
node.railingMaterialPreset,
|
|
||||||
node.sideMaterial,
|
|
||||||
node.sideMaterialPreset,
|
|
||||||
node.treadMaterial,
|
|
||||||
node.treadMaterialPreset,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
const railingMaterial = useMemo(
|
const railingMaterial = useMemo(() => getStairRailingMaterial(node), [node])
|
||||||
() => getStairRailingMaterial(node),
|
|
||||||
[
|
|
||||||
node.material,
|
|
||||||
node.materialPreset,
|
|
||||||
node.railingMaterial,
|
|
||||||
node.railingMaterialPreset,
|
|
||||||
node.sideMaterial,
|
|
||||||
node.sideMaterialPreset,
|
|
||||||
node.treadMaterial,
|
|
||||||
node.treadMaterialPreset,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
const straightPlaceholderGeometry = useMemo(() => {
|
const straightPlaceholderGeometry = useMemo(() => {
|
||||||
const geometry = new THREE.BufferGeometry()
|
const geometry = new THREE.BufferGeometry()
|
||||||
@@ -132,7 +112,9 @@ export const StairRenderer = ({ node }: { node: StairNode }) => {
|
|||||||
receiveShadow
|
receiveShadow
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{!isSegmentBasedStair ? <CurvedStairBody bodyMaterials={straightBodyMaterials} stair={node} /> : null}
|
{!isSegmentBasedStair ? (
|
||||||
|
<CurvedStairBody bodyMaterials={straightBodyMaterials} stair={node} />
|
||||||
|
) : null}
|
||||||
<StairRailings material={railingMaterial} stair={node} />
|
<StairRailings material={railingMaterial} stair={node} />
|
||||||
{isSegmentBasedStair ? (
|
{isSegmentBasedStair ? (
|
||||||
<group name="segments-wrapper" visible={false}>
|
<group name="segments-wrapper" visible={false}>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { SceneRenderer } from '../renderers/scene-renderer'
|
|||||||
import FrameLimiter from './frame-limiter'
|
import FrameLimiter from './frame-limiter'
|
||||||
import { Lights } from './lights'
|
import { Lights } from './lights'
|
||||||
import { PerfMonitor } from './perf-monitor'
|
import { PerfMonitor } from './perf-monitor'
|
||||||
import PostProcessing from './post-processing'
|
import PostProcessing, { DEFAULT_HOVER_STYLES, type HoverStyles } from './post-processing'
|
||||||
import { SelectionManager } from './selection-manager'
|
import { SelectionManager } from './selection-manager'
|
||||||
import { ViewerCamera } from './viewer-camera'
|
import { ViewerCamera } from './viewer-camera'
|
||||||
|
|
||||||
@@ -101,12 +101,14 @@ function GPUDeviceWatcher() {
|
|||||||
|
|
||||||
interface ViewerProps {
|
interface ViewerProps {
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
|
hoverStyles?: HoverStyles
|
||||||
selectionManager?: 'default' | 'custom'
|
selectionManager?: 'default' | 'custom'
|
||||||
perf?: boolean
|
perf?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const Viewer: React.FC<ViewerProps> = ({
|
const Viewer: React.FC<ViewerProps> = ({
|
||||||
children,
|
children,
|
||||||
|
hoverStyles = DEFAULT_HOVER_STYLES,
|
||||||
selectionManager = 'default',
|
selectionManager = 'default',
|
||||||
perf = false,
|
perf = false,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -165,7 +167,7 @@ const Viewer: React.FC<ViewerProps> = ({
|
|||||||
<WallSystem />
|
<WallSystem />
|
||||||
<WindowSystem />
|
<WindowSystem />
|
||||||
<ZoneSystem />
|
<ZoneSystem />
|
||||||
<PostProcessing />
|
<PostProcessing hoverStyles={hoverStyles} />
|
||||||
{/* <DebugRenderer /> */}
|
{/* <DebugRenderer /> */}
|
||||||
<GPUDeviceWatcher />
|
<GPUDeviceWatcher />
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,28 @@ const RETRY_DELAY_MS = 500
|
|||||||
const DARK_BG = '#1f2433'
|
const DARK_BG = '#1f2433'
|
||||||
const LIGHT_BG = '#ffffff'
|
const LIGHT_BG = '#ffffff'
|
||||||
|
|
||||||
|
export type HoverStyle = {
|
||||||
|
visibleColor: number
|
||||||
|
hiddenColor: number
|
||||||
|
strength: number
|
||||||
|
pulse: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HoverStyles = {
|
||||||
|
default: HoverStyle
|
||||||
|
} & Record<string, HoverStyle>
|
||||||
|
|
||||||
|
const DEFAULT_HOVER_STYLE: HoverStyle = {
|
||||||
|
visibleColor: 0x00_aa_ff,
|
||||||
|
hiddenColor: 0xf3_ff_47,
|
||||||
|
strength: 5,
|
||||||
|
pulse: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_HOVER_STYLES: HoverStyles = {
|
||||||
|
default: DEFAULT_HOVER_STYLE,
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeOutlineObjects(objects: Object3D[]) {
|
function sanitizeOutlineObjects(objects: Object3D[]) {
|
||||||
let nextIndex = 0
|
let nextIndex = 0
|
||||||
|
|
||||||
@@ -62,8 +84,12 @@ function sanitizeOutlineObjects(objects: Object3D[]) {
|
|||||||
objects.length = nextIndex
|
objects.length = nextIndex
|
||||||
}
|
}
|
||||||
|
|
||||||
const PostProcessingPasses = () => {
|
const PostProcessingPasses = ({
|
||||||
const { gl: renderer, scene, camera } = useThree()
|
hoverStyles = DEFAULT_HOVER_STYLES,
|
||||||
|
}: {
|
||||||
|
hoverStyles?: HoverStyles
|
||||||
|
}) => {
|
||||||
|
const { gl: renderer, invalidate, scene, camera } = useThree()
|
||||||
const renderPipelineRef = useRef<RenderPipeline | null>(null)
|
const renderPipelineRef = useRef<RenderPipeline | null>(null)
|
||||||
const hasPipelineErrorRef = useRef(false)
|
const hasPipelineErrorRef = useRef(false)
|
||||||
const retryCountRef = useRef(0)
|
const retryCountRef = useRef(0)
|
||||||
@@ -83,6 +109,10 @@ const PostProcessingPasses = () => {
|
|||||||
return l
|
return l
|
||||||
}, [])
|
}, [])
|
||||||
const hoverHighlightMode = useViewer((s) => s.hoverHighlightMode)
|
const hoverHighlightMode = useViewer((s) => s.hoverHighlightMode)
|
||||||
|
const hoverVisibleColor = useMemo(() => uniform(new Color(DEFAULT_HOVER_STYLE.visibleColor)), [])
|
||||||
|
const hoverHiddenColor = useMemo(() => uniform(new Color(DEFAULT_HOVER_STYLE.hiddenColor)), [])
|
||||||
|
const hoverStrength = useMemo(() => uniform(DEFAULT_HOVER_STYLE.strength), [])
|
||||||
|
const hoverPulseMix = useMemo(() => uniform(DEFAULT_HOVER_STYLE.pulse ? 0 : 1), [])
|
||||||
|
|
||||||
// Subscribe to projectId so the pipeline rebuilds on project switch
|
// Subscribe to projectId so the pipeline rebuilds on project switch
|
||||||
const projectId = useViewer((s) => s.projectId)
|
const projectId = useViewer((s) => s.projectId)
|
||||||
@@ -119,6 +149,23 @@ const PostProcessingPasses = () => {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const style = hoverStyles[hoverHighlightMode] ?? hoverStyles.default
|
||||||
|
hoverVisibleColor.value.setHex(style.visibleColor)
|
||||||
|
hoverHiddenColor.value.setHex(style.hiddenColor)
|
||||||
|
hoverStrength.value = style.strength
|
||||||
|
hoverPulseMix.value = style.pulse ? 0 : 1
|
||||||
|
invalidate()
|
||||||
|
}, [
|
||||||
|
hoverHiddenColor,
|
||||||
|
hoverHighlightMode,
|
||||||
|
hoverPulseMix,
|
||||||
|
hoverStrength,
|
||||||
|
hoverStyles,
|
||||||
|
hoverVisibleColor,
|
||||||
|
invalidate,
|
||||||
|
])
|
||||||
|
|
||||||
// Build / rebuild the post-processing pipeline
|
// Build / rebuild the post-processing pipeline
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Intentionally touch these so React/biome treat project switches and retry bumps
|
// Intentionally touch these so React/biome treat project switches and retry bumps
|
||||||
@@ -248,18 +295,9 @@ const PostProcessingPasses = () => {
|
|||||||
.mul(selectedStrength)
|
.mul(selectedStrength)
|
||||||
|
|
||||||
// Hovered: blue visible, yellow hidden, pulsing
|
// Hovered: blue visible, yellow hidden, pulsing
|
||||||
const hoverVisibleColor = uniform(
|
|
||||||
new Color(hoverHighlightMode === 'delete' ? 0xef_44_44 : 0x00_aa_ff),
|
|
||||||
)
|
|
||||||
const hoverHiddenColor = uniform(
|
|
||||||
new Color(hoverHighlightMode === 'delete' ? 0x99_1b_1b : 0xf3_ff_47),
|
|
||||||
)
|
|
||||||
const hoverStrength = uniform(hoverHighlightMode === 'delete' ? 6 : 5)
|
|
||||||
const pulsePeriod = uniform(3)
|
const pulsePeriod = uniform(3)
|
||||||
const osc =
|
const oscillating = oscSine(time.div(pulsePeriod).mul(2)).mul(0.5).add(0.5)
|
||||||
hoverHighlightMode === 'delete'
|
const osc = mix(oscillating, float(1), hoverPulseMix)
|
||||||
? float(1)
|
|
||||||
: oscSine(time.div(pulsePeriod).mul(2)).mul(0.5).add(0.5) // [ 0.5, 1.0 ]
|
|
||||||
const hoverOutline = outlineNode.secondaryVisibleEdge
|
const hoverOutline = outlineNode.secondaryVisibleEdge
|
||||||
.mul(hoverVisibleColor)
|
.mul(hoverVisibleColor)
|
||||||
.add(outlineNode.secondaryHiddenEdge.mul(hoverHiddenColor))
|
.add(outlineNode.secondaryHiddenEdge.mul(hoverHiddenColor))
|
||||||
@@ -298,7 +336,18 @@ const PostProcessingPasses = () => {
|
|||||||
}
|
}
|
||||||
renderPipelineRef.current = null
|
renderPipelineRef.current = null
|
||||||
}
|
}
|
||||||
}, [renderer, scene, camera, hoverHighlightMode, zoneLayers, projectId, pipelineVersion])
|
}, [
|
||||||
|
camera,
|
||||||
|
hoverHiddenColor,
|
||||||
|
hoverPulseMix,
|
||||||
|
hoverStrength,
|
||||||
|
hoverVisibleColor,
|
||||||
|
pipelineVersion,
|
||||||
|
projectId,
|
||||||
|
renderer,
|
||||||
|
scene,
|
||||||
|
zoneLayers,
|
||||||
|
])
|
||||||
|
|
||||||
useFrame((_, delta) => {
|
useFrame((_, delta) => {
|
||||||
// Animate background colour toward the current theme target (same lerp as AnimatedBackground)
|
// Animate background colour toward the current theme target (same lerp as AnimatedBackground)
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
export { default as Viewer } from './components/viewer'
|
export { default as Viewer } from './components/viewer'
|
||||||
export { SSGI_PARAMS } from './components/viewer/post-processing'
|
export type { HoverStyle, HoverStyles } from './components/viewer/post-processing'
|
||||||
|
export {
|
||||||
|
DEFAULT_HOVER_STYLES,
|
||||||
|
SSGI_PARAMS,
|
||||||
|
} from './components/viewer/post-processing'
|
||||||
export { WalkthroughControls } from './components/viewer/walkthrough-controls'
|
export { WalkthroughControls } from './components/viewer/walkthrough-controls'
|
||||||
export { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url'
|
export { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url'
|
||||||
export { SCENE_LAYER, ZONE_LAYER } from './lib/layers'
|
export { SCENE_LAYER, ZONE_LAYER } from './lib/layers'
|
||||||
export {
|
export {
|
||||||
|
applyMaterialPresetToMaterials,
|
||||||
clearMaterialCache,
|
clearMaterialCache,
|
||||||
createDefaultMaterial,
|
createDefaultMaterial,
|
||||||
createMaterial,
|
createMaterial,
|
||||||
|
createMaterialFromPresetRef,
|
||||||
DEFAULT_CEILING_MATERIAL,
|
DEFAULT_CEILING_MATERIAL,
|
||||||
DEFAULT_DOOR_MATERIAL,
|
DEFAULT_DOOR_MATERIAL,
|
||||||
DEFAULT_ROOF_MATERIAL,
|
DEFAULT_ROOF_MATERIAL,
|
||||||
@@ -19,3 +25,6 @@ export { mergedOutline } from './lib/merged-outline-node'
|
|||||||
export { default as useViewer } from './store/use-viewer'
|
export { default as useViewer } from './store/use-viewer'
|
||||||
export { InteractiveSystem } from './systems/interactive/interactive-system'
|
export { InteractiveSystem } from './systems/interactive/interactive-system'
|
||||||
export { snapLevelsToTruePositions } from './systems/level/level-utils'
|
export { snapLevelsToTruePositions } from './systems/level/level-utils'
|
||||||
|
export { getRoofMaterialArray } from './systems/roof/roof-materials'
|
||||||
|
export { getStairBodyMaterials, getStairRailingMaterial } from './systems/stair/stair-materials'
|
||||||
|
export { getVisibleWallMaterials } from './systems/wall/wall-materials'
|
||||||
|
|||||||
+9
-3
@@ -1,4 +1,10 @@
|
|||||||
import type { AnyNode, BaseNode, BuildingNode, LevelNode, ZoneNode } from '@pascal-app/core'
|
import type {
|
||||||
|
AnyNode,
|
||||||
|
BaseNode,
|
||||||
|
BuildingNode,
|
||||||
|
LevelNode,
|
||||||
|
ZoneNode,
|
||||||
|
} from '@pascal-app/core'
|
||||||
import type { Object3D } from 'three'
|
import type { Object3D } from 'three'
|
||||||
type SelectionPath = {
|
type SelectionPath = {
|
||||||
buildingId: BuildingNode['id'] | null
|
buildingId: BuildingNode['id'] | null
|
||||||
@@ -14,8 +20,8 @@ type ViewerState = {
|
|||||||
selection: SelectionPath
|
selection: SelectionPath
|
||||||
previewSelectedIds: BaseNode['id'][]
|
previewSelectedIds: BaseNode['id'][]
|
||||||
setPreviewSelectedIds: (ids: BaseNode['id'][]) => void
|
setPreviewSelectedIds: (ids: BaseNode['id'][]) => void
|
||||||
hoverHighlightMode: 'default' | 'delete'
|
hoverHighlightMode: string
|
||||||
setHoverHighlightMode: (mode: 'default' | 'delete') => void
|
setHoverHighlightMode: (mode: string) => void
|
||||||
hoveredId: AnyNode['id'] | ZoneNode['id'] | null
|
hoveredId: AnyNode['id'] | ZoneNode['id'] | null
|
||||||
setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void
|
setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void
|
||||||
cameraMode: 'perspective' | 'orthographic'
|
cameraMode: 'perspective' | 'orthographic'
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ type ViewerState = {
|
|||||||
selection: SelectionPath
|
selection: SelectionPath
|
||||||
previewSelectedIds: BaseNode['id'][]
|
previewSelectedIds: BaseNode['id'][]
|
||||||
setPreviewSelectedIds: (ids: BaseNode['id'][]) => void
|
setPreviewSelectedIds: (ids: BaseNode['id'][]) => void
|
||||||
hoverHighlightMode: 'default' | 'delete'
|
hoverHighlightMode: string
|
||||||
setHoverHighlightMode: (mode: 'default' | 'delete') => void
|
setHoverHighlightMode: (mode: string) => void
|
||||||
hoveredId: AnyNode['id'] | ZoneNode['id'] | null
|
hoveredId: AnyNode['id'] | ZoneNode['id'] | null
|
||||||
setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void
|
setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void
|
||||||
|
|
||||||
@@ -85,9 +85,10 @@ const useViewer = create<ViewerState>()(
|
|||||||
previewSelectedIds: [],
|
previewSelectedIds: [],
|
||||||
setPreviewSelectedIds: (ids) => set({ previewSelectedIds: ids }),
|
setPreviewSelectedIds: (ids) => set({ previewSelectedIds: ids }),
|
||||||
hoverHighlightMode: 'default',
|
hoverHighlightMode: 'default',
|
||||||
setHoverHighlightMode: (mode) => set({ hoverHighlightMode: mode }),
|
setHoverHighlightMode: (mode) =>
|
||||||
|
set((state) => (state.hoverHighlightMode === mode ? state : { hoverHighlightMode: mode })),
|
||||||
hoveredId: null,
|
hoveredId: null,
|
||||||
setHoveredId: (id) => set({ hoveredId: id }),
|
setHoveredId: (id) => set((state) => (state.hoveredId === id ? state : { hoveredId: id })),
|
||||||
|
|
||||||
cameraMode: 'perspective',
|
cameraMode: 'perspective',
|
||||||
setCameraMode: (mode) => set({ cameraMode: mode }),
|
setCameraMode: (mode) => set({ cameraMode: mode }),
|
||||||
|
|||||||
@@ -8,6 +8,17 @@ import { createMaterial, createMaterialFromPresetRef } from '../../lib/materials
|
|||||||
|
|
||||||
export type RoofMaterialArray = [THREE.Material, THREE.Material, THREE.Material, THREE.Material]
|
export type RoofMaterialArray = [THREE.Material, THREE.Material, THREE.Material, THREE.Material]
|
||||||
|
|
||||||
|
const roofMaterialArrayCache = new Map<string, RoofMaterialArray>()
|
||||||
|
|
||||||
|
function getSurfaceMaterialSignature(
|
||||||
|
spec: ReturnType<typeof getEffectiveRoofSurfaceMaterial>,
|
||||||
|
): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
material: spec.material ?? null,
|
||||||
|
materialPreset: spec.materialPreset ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function createResolvedMaterial(
|
function createResolvedMaterial(
|
||||||
material: RoofNode['material'] | RoofSegmentNode['material'] | undefined,
|
material: RoofNode['material'] | RoofSegmentNode['material'] | undefined,
|
||||||
materialPreset: string | undefined,
|
materialPreset: string | undefined,
|
||||||
@@ -27,6 +38,14 @@ export function getRoofMaterialArray(node: RoofNode): RoofMaterialArray | null {
|
|||||||
const top = getEffectiveRoofSurfaceMaterial(node, 'top')
|
const top = getEffectiveRoofSurfaceMaterial(node, 'top')
|
||||||
const edge = getEffectiveRoofSurfaceMaterial(node, 'edge')
|
const edge = getEffectiveRoofSurfaceMaterial(node, 'edge')
|
||||||
const wall = getEffectiveRoofSurfaceMaterial(node, 'wall')
|
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 topMaterial = createResolvedMaterial(top.material, top.materialPreset)
|
||||||
const edgeMaterial = createResolvedMaterial(edge.material, edge.materialPreset)
|
const edgeMaterial = createResolvedMaterial(edge.material, edge.materialPreset)
|
||||||
@@ -36,11 +55,13 @@ export function getRoofMaterialArray(node: RoofNode): RoofMaterialArray | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
const materialArray: RoofMaterialArray = [
|
||||||
edgeMaterial ?? wallMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(),
|
edgeMaterial ?? wallMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(),
|
||||||
wallMaterial ?? edgeMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(),
|
wallMaterial ?? edgeMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(),
|
||||||
wallMaterial ?? edgeMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(),
|
wallMaterial ?? edgeMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(),
|
||||||
topMaterial ?? wallMaterial ?? edgeMaterial ?? new THREE.MeshStandardMaterial(),
|
topMaterial ?? wallMaterial ?? edgeMaterial ?? new THREE.MeshStandardMaterial(),
|
||||||
]
|
]
|
||||||
}
|
|
||||||
|
|
||||||
|
roofMaterialArrayCache.set(cacheKey, materialArray)
|
||||||
|
return materialArray
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,18 @@ import {
|
|||||||
|
|
||||||
export type StairBodyMaterials = [THREE.Material, THREE.Material]
|
export type StairBodyMaterials = [THREE.Material, THREE.Material]
|
||||||
|
|
||||||
|
const stairBodyMaterialCache = new Map<string, StairBodyMaterials>()
|
||||||
|
const stairRailingMaterialCache = new Map<string, THREE.Material>()
|
||||||
|
|
||||||
|
function getSurfaceMaterialSignature(
|
||||||
|
spec: ReturnType<typeof getEffectiveStairSurfaceMaterial>,
|
||||||
|
): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
material: spec.material ?? null,
|
||||||
|
materialPreset: spec.materialPreset ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function createResolvedMaterial(
|
function createResolvedMaterial(
|
||||||
material: StairNode['material'] | StairSegmentNode['material'] | undefined,
|
material: StairNode['material'] | StairSegmentNode['material'] | undefined,
|
||||||
materialPreset: string | undefined,
|
materialPreset: string | undefined,
|
||||||
@@ -30,16 +42,32 @@ function createResolvedMaterial(
|
|||||||
export function getStairBodyMaterials(stair: StairNode): StairBodyMaterials {
|
export function getStairBodyMaterials(stair: StairNode): StairBodyMaterials {
|
||||||
const tread = getEffectiveStairSurfaceMaterial(stair, 'tread')
|
const tread = getEffectiveStairSurfaceMaterial(stair, 'tread')
|
||||||
const side = getEffectiveStairSurfaceMaterial(stair, 'side')
|
const side = getEffectiveStairSurfaceMaterial(stair, 'side')
|
||||||
|
const cacheKey = JSON.stringify({
|
||||||
|
tread: getSurfaceMaterialSignature(tread),
|
||||||
|
side: getSurfaceMaterialSignature(side),
|
||||||
|
})
|
||||||
|
|
||||||
return [
|
const cached = stairBodyMaterialCache.get(cacheKey)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
const materials: StairBodyMaterials = [
|
||||||
createResolvedMaterial(tread.material, tread.materialPreset),
|
createResolvedMaterial(tread.material, tread.materialPreset),
|
||||||
createResolvedMaterial(side.material, side.materialPreset),
|
createResolvedMaterial(side.material, side.materialPreset),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
stairBodyMaterialCache.set(cacheKey, materials)
|
||||||
|
return materials
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStairRailingMaterial(stair: StairNode): THREE.Material {
|
export function getStairRailingMaterial(stair: StairNode): THREE.Material {
|
||||||
const railing = getEffectiveStairSurfaceMaterial(stair, 'railing')
|
const railing = getEffectiveStairSurfaceMaterial(stair, 'railing')
|
||||||
return createResolvedMaterial(railing.material, railing.materialPreset)
|
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(
|
export function getStraightStairSegmentBodyMaterials(
|
||||||
|
|||||||
Reference in New Issue
Block a user