nodes: add modular cabinets and wall trim controls (#461)
* Add roof surface placement support for items Items (e.g. solar panels) can now be placed on sloped roof surfaces. The placement system computes euler rotation from the roof surface normal so items sit flush on the slope instead of going inside. - Add roofStrategy to placement-strategies with enter/move/click/leave - Wire roof:enter/move/click/leave events in the placement coordinator - Add calculateRoofRotation in placement-math using surface normals - Support full 3D cursor rotation for sloped surfaces - Items on roofs are parented to the level with world-space rotation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fixed conflict * Add modular cabinet node * Add modular cabinet run and tall cabinet workflows * Add cabinet paint slots and material groups * Improve modular cabinet presets and wall snapping * Improve cabinet wall snapping * Fix cabinet wall snapping and countertop joins * Improve cabinet appliance compartments * Improve fridge visuals and smooth cabinet animations Fridge exterior is now brushed silver with brass accents and the interior is all white. Door/drawer animation no longer rebuilds the full cabinet geometry per frame — animated parts carry pose metadata that a new per-frame cabinet system applies directly to transforms. Co-Authored-By: Claude <noreply@anthropic.com> * Improve gas hob flames and cooktop knob controls Port the curved-flame look from reference gas-burner photos: each burner gets a ring of vertex-coloured tube flames (blue body, orange-yellow tips) whose spines breathe and flicker per frame at ~30fps, plus a flat ignition glow and a faded heat halo. Fix the knob pointer notch to rotate with the knob instead of drifting sideways. Also includes cooktop compartment presets/panels and cabinet selection/move affordance work. Co-Authored-By: Claude <noreply@anthropic.com> * Fix cabinet UVs and registry actions * Fix registry move snap typecheck * Fix cabinet knob animation concurrency * Fix cabinet material disposal, undo flood, and 2D move parity - Flag shared cabinet appliance materials (and the viewer's cached material factories) as cached so geometry rebuilds no longer dispose materials still referenced by other nodes, forcing scene-wide shader recompiles. - Route the door/drawer open animation through useLiveNodeOverrides with a single final commit, so one play is one undo step instead of ~20. - Add a cabinet-module floorplanMoveTarget: 2D drags now convert through planToLocal + magneticSnap like the 3D path, instead of writing plan coords into the run-local position (teleporting modules on rotated runs). - Re-key sibling runs (cabinetAdjacencyRevision) when a run's neighbor-affecting inputs change, so countertop overhang joins re-trim when a neighbor moves/resizes/deletes. - Narrow the cabinet panel's scene subscriptions (useShallow module selector) so it stops re-rendering on every scene mutation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Restructure cabinet schema and centralize run math/mutations Prepares the cabinet node for the queued specialty units (corner L-shape, sink base, appliance gap, open shelving): - Add a `moduleKind` discriminator (default 'standard') on CabinetModuleNode so new unit types extend an enum instead of overloading the stack, and split CabinetCompartment into a z.discriminatedUnion so invalid field combos (drawer with rack style, fridge with burner state) are unrepresentable. Shared box fields now come from one `cabinetBoxFields` object so run/module schemas can't drift. - Extract the straight-line run assumption (sort-by-x, edges, adjacency, spans, side-insert, reflow, frame transforms) into run-layout.ts — previously spread across definition/geometry/quick-actions/stack/ move-frame, so corner support would have meant five parallel edits. - Consolidate the run mutations (add module, wall cabinet/hood above, base↔tall switch, layout-revision bump) into run-ops.ts on SceneApi. Panel and quick-actions had drifted copies: the panel's add-module skipped gap checks, revision-bump scope differed per surface. - Remove dead code: node-level doorStyle (with a migrateNodes entry — geometry only ever read per-compartment doorType), the unreachable slot-handle mesh block, the no-op handlePosition 'edge' enum value, wallLocalX, and duplicate totalCabinetHeight definitions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add cabinet snap tests and split the cabinet panel - New tests: cabinetModuleParentFrame.magneticSnap (flush snap, threshold, Z alignment, nearest-edge) and wall-snap's resolveCabinetWallFaceOffset / collectCabinetWallSnapNeighbors (straight face, miter taper, ray-miss fallback, yaw/face/parent filtering) — 87 cabinet tests, up from 71. - De-brittle geometry test lookups: coordinate-encoded mesh names are now matched by pattern, so dimension-default changes don't break them. - Split panel.tsx (1,541 → 679 lines): CompartmentCard + option constants into compartment-card.tsx, CabinetRunPanel + reflow wiring into run-panel.tsx, and the compartment type-transition tables into a pure resolveCompartmentTransition in stack-transitions.ts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Split cabinet geometry.ts into per-appliance modules Pure code motion: geometry.ts (4,728 lines) becomes a 336-line orchestrator dispatching into geometry/ — shared helpers + cached appliance materials (shared.ts), CSG fronts/handles/doors/drawers (fronts.ts), run spans + countertop (run.ts), and self-contained hood/fridge/cooktop/dishwasher/ oven-microwave/pantry builders. No mesh names, userData stamps, materials, or math changed; the './geometry' import path is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add undermount sink compartment with CSG-cut countertops New 'sink' compartment type (single / double / 60-40 bowl layouts) following the cooktop pattern: zero stack height, countertop-plane geometry, panel card with a bowl-layout selector, and a Sink Base preset. Bowl openings are subtracted from both module- and run-owned countertop slabs via three-bvh-csg; the basin shells, drain plumbing, and a spec-sheet-proportioned gooseneck faucet (Grohe Minta-style pin lever) render beneath the cut. Sink modules skip the carcass top panel and the deck under the sink row so the basin hangs through. Also scope the viewer mock in roof-surface-placement-guides.test.ts to only stub useViewer — the bare module stub leaked into later suites and broke real CSG imports. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Update ifc-converter next-env reference to build-mode route types Regenerated by `next typegen` during check-types; points at .next/types instead of the dev-mode .next/dev/types path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Draw cabinet floor plans with kitchen drafting symbols Replace the per-module box soup with NKBA/architectural-convention symbols: the run draws one heavier countertop outline per span (extended by the overhang), base modules draw their front edge plus compartment symbols — rounded bowl rects + faucet dot for sinks, burner/zone rings for cooktops (reusing the 3D layout tables so 2D and 3D always agree) — and appliance modules carry standard upright labels (DW / REF / OV / MW / PAN). Nested wall cabinets and hood-only modules draw as dashed open outlines per the above-cut-plane convention. * Play cabinet open/close animation on the E interaction key Add an `e` slot to registry KeyboardActions and dispatch it in the keyboard hook ahead of the legacy door/window arms, so kinds opt into the E interaction on their NodeDefinition. Cabinets register it: E on a module eases its doors/drawers open or closed, E on a run swings every child module together, and hood-only modules fall through. The panel's rAF animator moves into cabinet/interaction.ts (live-override frames, single undo commit) so the Play button and E share one animation, and the shortcuts dialog now documents E. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add kitchen island and bar counter support to cabinet runs Islands: run-level countertopBackOverhang (seating side) and withFinishedBack (decorative back panel), plus an I-key island placement mode in the cabinet tool that skips wall snap. Bar counters: optional barLedge {edge, height, depth} drawing a knee wall and raised slab along the back or either run end, superseding the seating overhang on its edge. withWaterfall drops slab-material panels to the floor on exposed run ends. 2D floorplan outlines, selection bounds, geometry keys, and run panel controls updated in parity; schemaVersion 3 -> 6. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Merge quick-action arrow and plus into one directional add glyph The add-left/add-right buttons showed two unrelated icons; a single plus-on-tail arrow reads as "add on this side". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Extend plugin discovery instead of replacing the host source Also track the ifc-converter next-env routes reference update. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add L-corner runs to modular cabinets with run-scoped grouping An "L Left/Right" quick action spawns a perpendicular base leg, wall leg, and wall bridge as metadata-linked runs parented to the SOURCE RUN, so the run stays the single group: selecting a base module selects only it, and the clicked module never becomes a container. Corner runs re-anchor when the source module resizes or moves (new MovableParentFrame.onCommit hook, mirrored in the 2D floorplan commit), run bounds fold in child leg runs, and deleting one corner member removes only that node while unlink patches keep the survivors' link metadata consistent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Delete emptied cabinet run groups in the same delete gesture Removing a run's last child (module or derived corner leg) left an empty group node in the scene graph and persisted data. onDeleteCascade now receives the gesture's pending delete ids so multi-select deletes count siblings as gone, and both cabinet kinds cascade the orphaned parent run away in one undo step. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Remove filler front gap treatment * Apply architecture review fixes across cabinet, plugin-trees, and framework Move cabinet semantics out of packages/editor behind registry capabilities (def.tree, selectionProxy.bypassDirectPick, IconRef quick-action icons, catalog tool field), route cabinet selection through a core selection-proxy helper, and make PanelWorkspace an opaque host tag. Fix the cabinet placement undo flood, dirtyNodes.add bypasses, normalized UVs on paintable slots, plugin-trees ghost layers/stale level transforms/untyped find-sync event, and cross-file store-mock pollution plus orphaned dist tests that broke combined test runs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Propagate front styling to L-corner runs even when re-layout bails Group style changes rode entirely on syncDerivedCornerRun, which bails silently when a later-drawn wall blocks the corner layout or a leg run gained modules outside the derived-run spec — leaving the L legs styled stale. Apply the style patch directly to every linked corner run before attempting the geometric re-layout. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Improve editor viewer integration * Refactor plugin panels and floorplan invalidation * UV-unwrap and material-assign roof-segment trim planes * Fix cabinet placement snap feedback * Add cabinet wall snap bypass * Fix cabinet continuous placement flow * Implement wall band material slots * Add corner addition preview functionality and related tests * Fix cabinet and wall band behavior * Fix architecture review blockers * Fix post-merge quality check * fix cursor review feedback * Fix cabinet placement type and quick action history * Improve cabinet placement feedback * Make wall bands configurable * Add wall trim profiles and default materials * Fix architecture review issues * Fix cabinet rotation and wall band visibility * Fix sink appliance paint persistence * Bake cabinet animations and preserve wall band material * Fix cabinet group duplication and module snapping * Fix cabinet duplication architecture * Fix cabinet floorplan test and make placement-type hint a live chip - floorplan.test.ts: pass the required liveTransforms map to cabinetFloorplanSiblingOverrides (the runtime callers always supply it) - ToolHint gains an optional generic `chip` contract (subscribe/value/ cycle/labels) so a kind can render a tool hint as a live mode chip — like the snapping/continuation chips — without the editor knowing the kind's store - cabinet: the I hint now shows the current value (Type: Cabinet / Type: Island) and is clickable to toggle Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Wassim SAMAD <wass08@gmail.com>
This commit is contained in:
co-authored by
Claude Fable 5
Wassim SAMAD
parent
7d6879c416
commit
c45808840c
@@ -26,7 +26,7 @@ import { wallSlots } from './slots'
|
||||
export const wallDefinition: NodeDefinition<typeof WallNode> = {
|
||||
kind: 'wall',
|
||||
snapProfile: 'structural',
|
||||
schemaVersion: 1,
|
||||
schemaVersion: 5,
|
||||
schema: WallNode,
|
||||
category: 'structure',
|
||||
surfaceRole: 'wall',
|
||||
@@ -59,10 +59,10 @@ export const wallDefinition: NodeDefinition<typeof WallNode> = {
|
||||
// preview through this entry rather than carrying a kind-name
|
||||
// arm.
|
||||
paint: wallPaint,
|
||||
// Declared paintable slots (interior / exterior) with their default
|
||||
// appearance — the same `{ slotId, label, default }` contract every other
|
||||
// paintable kind exposes. Paint still writes the legacy inline fields via
|
||||
// `wallPaint`; migrating those into `node.slots` is a later step.
|
||||
// Declared paintable slots with their default appearance — the same
|
||||
// `{ slotId, label, default }` contract every other paintable kind exposes.
|
||||
// Paint still writes the legacy inline fields for base faces via
|
||||
// `wallPaint`; migrating those fully into `node.slots` is a later step.
|
||||
slots: () => wallSlots(),
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { sceneRegistry, type WallNode } from '@pascal-app/core'
|
||||
import { BoxGeometry, Mesh, MeshBasicMaterial, Object3D, Ray, Vector3 } from 'three'
|
||||
import { resolveWallRole, wallPaint } from './paint'
|
||||
|
||||
const baseWall: WallNode = {
|
||||
object: 'node',
|
||||
id: 'wall_test',
|
||||
type: 'wall',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
children: [],
|
||||
start: [0, 0],
|
||||
end: [4, 0],
|
||||
height: 2.5,
|
||||
thickness: 0.1,
|
||||
faceBands: { enabled: true, count: 3, lowerHeight: 0.9, middleHeight: 0.12, upperHeight: 0.61 },
|
||||
frontSide: 'interior',
|
||||
backSide: 'exterior',
|
||||
}
|
||||
|
||||
describe('resolveWallRole', () => {
|
||||
test('maps one wall face to lower, middle, and upper band slots by hit height', () => {
|
||||
expect(
|
||||
resolveWallRole({
|
||||
node: baseWall,
|
||||
materialIndex: 1,
|
||||
normal: [0, 0, 1],
|
||||
localPosition: [1, 0.2, 0.05],
|
||||
}),
|
||||
).toBe('lowerInterior')
|
||||
|
||||
expect(
|
||||
resolveWallRole({
|
||||
node: baseWall,
|
||||
materialIndex: 1,
|
||||
normal: [0, 0, 1],
|
||||
localPosition: [1, 0.95, 0.05],
|
||||
}),
|
||||
).toBe('middleInterior')
|
||||
|
||||
expect(
|
||||
resolveWallRole({
|
||||
node: baseWall,
|
||||
materialIndex: 1,
|
||||
normal: [0, 0, 1],
|
||||
localPosition: [1, 1.3, 0.05],
|
||||
}),
|
||||
).toBe('upperInterior')
|
||||
})
|
||||
|
||||
test('uses adjusted band heights from the wall config', () => {
|
||||
const wall = {
|
||||
...baseWall,
|
||||
faceBands: {
|
||||
enabled: true,
|
||||
count: 3,
|
||||
lowerHeight: 1.2,
|
||||
middleHeight: 0.2,
|
||||
upperHeight: 0.61,
|
||||
},
|
||||
}
|
||||
|
||||
expect(
|
||||
resolveWallRole({
|
||||
node: wall,
|
||||
materialIndex: 2,
|
||||
normal: [0, 0, -1],
|
||||
localPosition: [1, 1.1, -0.05],
|
||||
}),
|
||||
).toBe('lowerExterior')
|
||||
|
||||
expect(
|
||||
resolveWallRole({
|
||||
node: wall,
|
||||
materialIndex: 2,
|
||||
normal: [0, 0, -1],
|
||||
localPosition: [1, 1.3, -0.05],
|
||||
}),
|
||||
).toBe('middleExterior')
|
||||
})
|
||||
|
||||
test('falls back to whole-side roles when bands are disabled', () => {
|
||||
const wall = {
|
||||
...baseWall,
|
||||
faceBands: {
|
||||
enabled: false,
|
||||
count: 1,
|
||||
lowerHeight: 0.9,
|
||||
middleHeight: 0.12,
|
||||
upperHeight: 0.61,
|
||||
},
|
||||
}
|
||||
|
||||
expect(
|
||||
resolveWallRole({
|
||||
node: wall,
|
||||
materialIndex: 1,
|
||||
normal: [0, 0, 1],
|
||||
localPosition: [1, 0.2, 0.05],
|
||||
}),
|
||||
).toBe('interior')
|
||||
})
|
||||
|
||||
test('maps four wall bands to lower, middle, upper, and top slots', () => {
|
||||
const wall = {
|
||||
...baseWall,
|
||||
faceBands: {
|
||||
enabled: true,
|
||||
count: 4,
|
||||
lowerHeight: 0.5,
|
||||
middleHeight: 0.5,
|
||||
upperHeight: 0.5,
|
||||
},
|
||||
}
|
||||
|
||||
expect(
|
||||
resolveWallRole({
|
||||
node: wall,
|
||||
materialIndex: 1,
|
||||
normal: [0, 0, 1],
|
||||
localPosition: [1, 0.2, 0.05],
|
||||
}),
|
||||
).toBe('lowerInterior')
|
||||
expect(
|
||||
resolveWallRole({
|
||||
node: wall,
|
||||
materialIndex: 1,
|
||||
normal: [0, 0, 1],
|
||||
localPosition: [1, 0.7, 0.05],
|
||||
}),
|
||||
).toBe('middleInterior')
|
||||
expect(
|
||||
resolveWallRole({
|
||||
node: wall,
|
||||
materialIndex: 1,
|
||||
normal: [0, 0, 1],
|
||||
localPosition: [1, 1.2, 0.05],
|
||||
}),
|
||||
).toBe('upperInterior')
|
||||
expect(
|
||||
resolveWallRole({
|
||||
node: wall,
|
||||
materialIndex: 1,
|
||||
normal: [0, 0, 1],
|
||||
localPosition: [1, 1.8, 0.05],
|
||||
}),
|
||||
).toBe('topInterior')
|
||||
})
|
||||
|
||||
test('falls back to whole-side roles by default', () => {
|
||||
const { faceBands, ...wall } = baseWall
|
||||
expect(
|
||||
resolveWallRole({
|
||||
node: wall,
|
||||
materialIndex: 1,
|
||||
normal: [0, 0, 1],
|
||||
localPosition: [1, 0.2, 0.05],
|
||||
}),
|
||||
).toBe('interior')
|
||||
})
|
||||
|
||||
test('uses direct trim slot hits and exposes trim default materials', () => {
|
||||
expect(
|
||||
resolveWallRole({
|
||||
node: baseWall,
|
||||
hitObject: { userData: { slotId: 'crownInterior' } },
|
||||
materialIndex: null,
|
||||
normal: undefined,
|
||||
localPosition: undefined,
|
||||
}),
|
||||
).toBe('crownInterior')
|
||||
|
||||
expect(
|
||||
wallPaint.getEffectiveMaterial?.({
|
||||
node: baseWall,
|
||||
role: 'crownInterior',
|
||||
nodes: { [baseWall.id]: baseWall },
|
||||
}),
|
||||
).toEqual({ material: undefined, materialPreset: 'library:preset-white' })
|
||||
})
|
||||
|
||||
test('prefers trim child ray hits over the broad wall face role', () => {
|
||||
const root = new Object3D()
|
||||
const trim = new Mesh(new BoxGeometry(1, 1, 0.08), new MeshBasicMaterial())
|
||||
trim.userData.slotId = 'crownInterior'
|
||||
root.add(trim)
|
||||
root.updateMatrixWorld(true)
|
||||
sceneRegistry.nodes.set(baseWall.id, root)
|
||||
|
||||
try {
|
||||
expect(
|
||||
resolveWallRole({
|
||||
node: baseWall,
|
||||
hitObject: { userData: {} },
|
||||
materialIndex: 1,
|
||||
normal: [0, 0, 1],
|
||||
localPosition: [0, 1, 0.05],
|
||||
ray: new Ray(new Vector3(0, 0, 1), new Vector3(0, 0, -1)),
|
||||
}),
|
||||
).toBe('crownInterior')
|
||||
} finally {
|
||||
sceneRegistry.nodes.delete(baseWall.id)
|
||||
trim.geometry.dispose()
|
||||
;(trim.material as MeshBasicMaterial).dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -2,20 +2,75 @@ import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
getEffectiveWallSurfaceMaterial,
|
||||
getWallBandSlotId,
|
||||
getWallFaceBandConfig,
|
||||
getWallFaceBandForHeight,
|
||||
getWallSurfaceSideFromBandSlot,
|
||||
type PaintCapability,
|
||||
type PaintPreviewArgs,
|
||||
parseMaterialRef,
|
||||
type SceneMaterialId,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
WALL_SURFACE_SLOT_DEFAULTS,
|
||||
type WallNode,
|
||||
type WallSurfaceSide,
|
||||
type WallSurfaceSlotId,
|
||||
} from '@pascal-app/core'
|
||||
import type { Material, Mesh } from 'three'
|
||||
import { buildSlotPreviewMaterial, createSlotPaintCapability } from '../shared/slot-paint'
|
||||
import { type Material, type Mesh, type Object3D, type Ray, Raycaster } from 'three'
|
||||
import {
|
||||
buildSlotPreviewMaterial,
|
||||
createSlotPaintCapability,
|
||||
previewSlotByUserData,
|
||||
} from '../shared/slot-paint'
|
||||
|
||||
const WALL_SLOT_IDS = new Set<string>(Object.keys(WALL_SURFACE_SLOT_DEFAULTS))
|
||||
const WALL_ARRAY_SLOT_INDEX: Partial<Record<WallSurfaceSlotId, number>> = {
|
||||
interior: 1,
|
||||
exterior: 2,
|
||||
lowerInterior: 3,
|
||||
middleInterior: 4,
|
||||
upperInterior: 5,
|
||||
topInterior: 6,
|
||||
lowerExterior: 7,
|
||||
middleExterior: 8,
|
||||
upperExterior: 9,
|
||||
topExterior: 10,
|
||||
}
|
||||
const WALL_INDEX_SLOT = new Map<number, WallSurfaceSlotId>(
|
||||
Object.entries(WALL_ARRAY_SLOT_INDEX).map(([slotId, index]) => [
|
||||
index,
|
||||
slotId as WallSurfaceSlotId,
|
||||
]),
|
||||
)
|
||||
const wallSlotRaycaster = new Raycaster()
|
||||
|
||||
function resolveSideFromMaterialIndex(materialIndex: number | null): WallSurfaceSide | null {
|
||||
const slotId = materialIndex === null ? undefined : WALL_INDEX_SLOT.get(materialIndex)
|
||||
if (slotId) return getWallSurfaceSideFromBandSlot(slotId)
|
||||
return null
|
||||
}
|
||||
|
||||
function resolveWallSlotByRay(node: WallNode, ray: Ray | undefined): WallSurfaceSlotId | null {
|
||||
if (!ray) return null
|
||||
const root = sceneRegistry.nodes.get(node.id as AnyNodeId)
|
||||
if (!root) return null
|
||||
|
||||
wallSlotRaycaster.ray.copy(ray)
|
||||
const hits = wallSlotRaycaster.intersectObject(root, true)
|
||||
for (const hit of hits) {
|
||||
const slotId = (hit.object as Object3D).userData?.slotId
|
||||
if (typeof slotId === 'string' && WALL_SLOT_IDS.has(slotId)) {
|
||||
return slotId as WallSurfaceSlotId
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which side of a wall the user clicked. Walls expose two
|
||||
* paintable surfaces — interior + exterior — split by:
|
||||
* 1. Material-slot index from the renderer's groups (1 = interior,
|
||||
* 2 = exterior). Cheap reference-equality path.
|
||||
* Resolve which wall face band the user clicked. The side comes from:
|
||||
* 1. Material-slot index from the renderer's groups. Cheap reference path.
|
||||
* 2. Falls back to the hit-surface normal + local-Z when the
|
||||
* groups aren't conclusive. Front/back of the wall maps to the
|
||||
* node's `frontSide` / `backSide` semantic; absent that, front
|
||||
@@ -26,13 +81,34 @@ import { buildSlotPreviewMaterial, createSlotPaintCapability } from '../shared/s
|
||||
*/
|
||||
export function resolveWallRole(args: {
|
||||
node: WallNode
|
||||
hitObject?: { userData?: { slotId?: unknown } }
|
||||
materialIndex: number | null
|
||||
normal: readonly [number, number, number] | undefined
|
||||
localPosition: readonly [number, number, number] | undefined
|
||||
}): WallSurfaceSide | null {
|
||||
const { node, materialIndex, normal, localPosition } = args
|
||||
if (materialIndex === 1) return 'interior'
|
||||
if (materialIndex === 2) return 'exterior'
|
||||
ray?: Ray
|
||||
}): string | null {
|
||||
const { node, hitObject, materialIndex, normal, localPosition, ray } = args
|
||||
const directSlotId = hitObject?.userData?.slotId
|
||||
if (typeof directSlotId === 'string' && WALL_SLOT_IDS.has(directSlotId)) {
|
||||
return directSlotId
|
||||
}
|
||||
|
||||
const raySlotId = resolveWallSlotByRay(node, ray)
|
||||
if (raySlotId) return raySlotId
|
||||
|
||||
const indexedSlotId = materialIndex === null ? undefined : WALL_INDEX_SLOT.get(materialIndex)
|
||||
const indexedSide = resolveSideFromMaterialIndex(materialIndex)
|
||||
const sideFromIndex = indexedSide ?? null
|
||||
if (indexedSlotId && indexedSlotId !== 'interior' && indexedSlotId !== 'exterior') {
|
||||
return indexedSlotId
|
||||
}
|
||||
|
||||
if (sideFromIndex && localPosition) {
|
||||
const bands = getWallFaceBandConfig(node)
|
||||
if (!bands.enabled) return sideFromIndex
|
||||
return getWallBandSlotId(sideFromIndex, getWallFaceBandForHeight(node, localPosition[1]))
|
||||
}
|
||||
if (sideFromIndex) return sideFromIndex
|
||||
|
||||
const normalZ = normal?.[2]
|
||||
const localZ = localPosition?.[2]
|
||||
@@ -41,6 +117,7 @@ export function resolveWallRole(args: {
|
||||
if (
|
||||
normalZ === undefined ||
|
||||
localZ === undefined ||
|
||||
localPosition === undefined ||
|
||||
Math.abs(normalZ) < 0.65 ||
|
||||
Math.abs(localZ) < Math.max(thickness * 0.2, 0.01)
|
||||
) {
|
||||
@@ -51,17 +128,15 @@ export function resolveWallRole(args: {
|
||||
const semantic = hitFace === 'front' ? node.frontSide : node.backSide
|
||||
|
||||
if (semantic === 'interior' || semantic === 'exterior') {
|
||||
return semantic
|
||||
const bands = getWallFaceBandConfig(node)
|
||||
if (!bands.enabled) return semantic
|
||||
return getWallBandSlotId(semantic, getWallFaceBandForHeight(node, localPosition[1]))
|
||||
}
|
||||
|
||||
return hitFace === 'front' ? 'interior' : 'exterior'
|
||||
}
|
||||
|
||||
// The wall's 3-material array maps side → group index (see
|
||||
// `getVisibleWallMaterials`): 0 = edge/cap, 1 = interior, 2 = exterior.
|
||||
const WALL_SIDE_MATERIAL_INDEX: Record<WallSurfaceSide, 1 | 2> = {
|
||||
interior: 1,
|
||||
exterior: 2,
|
||||
const side = hitFace === 'front' ? 'interior' : 'exterior'
|
||||
const bands = getWallFaceBandConfig(node)
|
||||
if (!bands.enabled) return side
|
||||
return getWallBandSlotId(side, getWallFaceBandForHeight(node, localPosition[1]))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,9 +147,12 @@ const WALL_SIDE_MATERIAL_INDEX: Record<WallSurfaceSide, 1 | 2> = {
|
||||
*/
|
||||
function applyWallPreview(args: PaintPreviewArgs): (() => void) | null {
|
||||
const { role, material, materialPreset } = args
|
||||
const side = role as WallSurfaceSide
|
||||
const index = WALL_SIDE_MATERIAL_INDEX[side]
|
||||
if (!index) return null
|
||||
if (!(role in WALL_ARRAY_SLOT_INDEX)) {
|
||||
return previewSlotByUserData(args)
|
||||
}
|
||||
|
||||
const index = WALL_ARRAY_SLOT_INDEX[role as WallSurfaceSlotId]
|
||||
if (!index) return previewSlotByUserData(args)
|
||||
|
||||
const mesh = sceneRegistry.nodes.get(args.node.id as AnyNodeId)
|
||||
if (!(mesh && (mesh as Mesh).isMesh)) return null
|
||||
@@ -98,18 +176,43 @@ function applyWallPreview(args: PaintPreviewArgs): (() => void) | null {
|
||||
|
||||
/**
|
||||
* Capability binding for the wall kind on the unified slot model. Painting
|
||||
* writes `node.slots[interior|exterior]` (a `library:` ref or a minted
|
||||
* `scene:` material) exactly like every other kind; `legacyEffective` reads
|
||||
* the retired inline `interiorMaterial*` / `exteriorMaterial*` fields so the
|
||||
* picker still shows the current value on a pre-migration scene.
|
||||
* writes `node.slots[bandSide]` (a `library:` ref or a minted `scene:`
|
||||
* material) exactly like every other kind; `legacyEffective` reads the
|
||||
* whole-side fallback so old scenes still show the current value.
|
||||
*/
|
||||
export const wallPaint: PaintCapability = createSlotPaintCapability({
|
||||
roomScope: true,
|
||||
resolveRole: ({ node, materialIndex, normal, localPosition }) =>
|
||||
resolveWallRole({ node: node as WallNode, materialIndex, normal, localPosition }),
|
||||
resolveRole: ({ node, hitObject, materialIndex, normal, localPosition, ray }) =>
|
||||
resolveWallRole({
|
||||
node: node as WallNode,
|
||||
hitObject: hitObject as { userData?: { slotId?: unknown } } | undefined,
|
||||
materialIndex,
|
||||
normal,
|
||||
localPosition,
|
||||
ray,
|
||||
}),
|
||||
applyPreview: applyWallPreview,
|
||||
legacyEffective: (node: AnyNode, role: string) => {
|
||||
const spec = getEffectiveWallSurfaceMaterial(node as WallNode, role as WallSurfaceSide)
|
||||
const side = getWallSurfaceSideFromBandSlot(role)
|
||||
if (!side && role in WALL_SURFACE_SLOT_DEFAULTS) {
|
||||
return {
|
||||
material: undefined,
|
||||
materialPreset: WALL_SURFACE_SLOT_DEFAULTS[role as WallSurfaceSlotId],
|
||||
}
|
||||
}
|
||||
if (!side) return null
|
||||
|
||||
const sideRef = (node as WallNode).slots?.[side]
|
||||
const parsed = parseMaterialRef(sideRef)
|
||||
if (parsed?.kind === 'library') {
|
||||
return { material: undefined, materialPreset: sideRef }
|
||||
}
|
||||
if (parsed?.kind === 'scene') {
|
||||
const sceneMaterial = useScene.getState().materials[parsed.id as SceneMaterialId]
|
||||
if (sceneMaterial) return { material: sceneMaterial.material, materialPreset: undefined }
|
||||
}
|
||||
|
||||
const spec = getEffectiveWallSurfaceMaterial(node as WallNode, side)
|
||||
if (spec.material === undefined && spec.materialPreset === undefined) return null
|
||||
return { material: spec.material, materialPreset: spec.materialPreset }
|
||||
},
|
||||
|
||||
@@ -3,13 +3,20 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
buildWallFaceBandCountPatch,
|
||||
getClampedWallCurveOffset,
|
||||
getMaxWallCurveOffset,
|
||||
getWallCurveLength,
|
||||
getWallFaceBandConfig,
|
||||
normalizeWallCurveOffset,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
WALL_CHAIR_RAIL_DEFAULT,
|
||||
WALL_CROWN_DEFAULT,
|
||||
WALL_FACE_BAND_DEFAULT,
|
||||
WALL_SKIRTING_DEFAULT,
|
||||
type WallNode,
|
||||
type WallTrimProfile,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
ActionButton,
|
||||
@@ -20,6 +27,7 @@ import {
|
||||
metersToLinearUnit,
|
||||
PanelSection,
|
||||
PanelWrapper,
|
||||
SegmentedControl,
|
||||
SliderControl,
|
||||
triggerSFX,
|
||||
useInteractionScope,
|
||||
@@ -28,6 +36,35 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { Spline } from 'lucide-react'
|
||||
import { useCallback, useMemo, useRef } from 'react'
|
||||
|
||||
type WallTrimKey = 'skirting' | 'crown' | 'chairRail'
|
||||
|
||||
const WALL_TRIM_PROFILE_OPTIONS: Record<
|
||||
WallTrimKey,
|
||||
Array<{ label: string; value: WallTrimProfile }>
|
||||
> = {
|
||||
skirting: [
|
||||
{ label: 'Flat', value: 'flat' },
|
||||
{ label: 'Modern', value: 'base-modern' },
|
||||
{ label: 'Colonial', value: 'base-colonial' },
|
||||
{ label: 'Shoe', value: 'base-shoe' },
|
||||
{ label: 'Ogee', value: 'base-ogee' },
|
||||
],
|
||||
crown: [
|
||||
{ label: 'Flat', value: 'flat' },
|
||||
{ label: 'Cove', value: 'crown-cove' },
|
||||
{ label: 'Ogee', value: 'crown-ogee' },
|
||||
{ label: 'Craft', value: 'crown-craftsman' },
|
||||
{ label: 'Layered', value: 'crown-layered' },
|
||||
],
|
||||
chairRail: [
|
||||
{ label: 'Flat', value: 'flat' },
|
||||
{ label: 'Round', value: 'rail-rounded' },
|
||||
{ label: 'Ogee', value: 'rail-ogee' },
|
||||
{ label: 'Picture', value: 'rail-picture' },
|
||||
{ label: 'Step', value: 'rail-stepped' },
|
||||
],
|
||||
}
|
||||
|
||||
export default function WallPanel() {
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||
const unit = useViewer((s) => s.unit)
|
||||
@@ -134,6 +171,11 @@ export default function WallPanel() {
|
||||
const displayCurveOffset = metersToLinearUnit(curveOffset, unit)
|
||||
const displayMaxCurveOffset = metersToLinearUnit(maxCurveOffset, unit)
|
||||
const curveOffsetLimit = Math.max(0.01, maxCurveOffset)
|
||||
const wallHeightMeters = node.height ?? 2.5
|
||||
|
||||
const skirting = { ...WALL_SKIRTING_DEFAULT, ...(node.skirting ?? {}) }
|
||||
const crown = { ...WALL_CROWN_DEFAULT, ...(node.crown ?? {}) }
|
||||
const chairRail = { ...WALL_CHAIR_RAIL_DEFAULT, ...(node.chairRail ?? {}) }
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
@@ -209,6 +251,45 @@ export default function WallPanel() {
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
<WallFaceBandSection
|
||||
node={node}
|
||||
onUpdate={handleUpdate}
|
||||
unit={unit}
|
||||
unitLabel={unitLabel}
|
||||
wallHeightMeters={wallHeightMeters}
|
||||
/>
|
||||
|
||||
<WallTrimSection
|
||||
node={node}
|
||||
onUpdate={handleUpdate}
|
||||
title="Skirting"
|
||||
trimKey="skirting"
|
||||
trimValue={skirting}
|
||||
unit={unit}
|
||||
unitLabel={unitLabel}
|
||||
wallHeightMeters={wallHeightMeters}
|
||||
/>
|
||||
<WallTrimSection
|
||||
node={node}
|
||||
onUpdate={handleUpdate}
|
||||
title="Crown molding"
|
||||
trimKey="crown"
|
||||
trimValue={crown}
|
||||
unit={unit}
|
||||
unitLabel={unitLabel}
|
||||
wallHeightMeters={wallHeightMeters}
|
||||
/>
|
||||
<WallTrimSection
|
||||
node={node}
|
||||
onUpdate={handleUpdate}
|
||||
title="Chair rail"
|
||||
trimKey="chairRail"
|
||||
trimValue={chairRail}
|
||||
unit={unit}
|
||||
unitLabel={unitLabel}
|
||||
wallHeightMeters={wallHeightMeters}
|
||||
/>
|
||||
|
||||
{!hasWallChildrenBlockingCurve && (
|
||||
<PanelSection title="Actions">
|
||||
<ActionGroup>
|
||||
@@ -223,3 +304,218 @@ export default function WallPanel() {
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
function WallFaceBandSection({
|
||||
node,
|
||||
onUpdate,
|
||||
unit,
|
||||
unitLabel,
|
||||
wallHeightMeters,
|
||||
}: {
|
||||
node: WallNode
|
||||
onUpdate: (updates: Partial<WallNode>) => void
|
||||
unit: 'metric' | 'imperial'
|
||||
unitLabel: string
|
||||
wallHeightMeters: number
|
||||
}) {
|
||||
const bandConfig = getWallFaceBandConfig(node)
|
||||
const bandCount = bandConfig.count
|
||||
const lowerHeight = bandConfig.lowerHeight
|
||||
const middleHeight = bandConfig.middleHeight
|
||||
const upperHeight = bandConfig.upperHeight
|
||||
const updateBands = (patch: Partial<NonNullable<WallNode['faceBands']>>) =>
|
||||
onUpdate({
|
||||
faceBands: {
|
||||
...WALL_FACE_BAND_DEFAULT,
|
||||
...(node.faceBands ?? {}),
|
||||
enabled: bandCount > 1,
|
||||
count: bandCount,
|
||||
...patch,
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<PanelSection title="Wall bands">
|
||||
<SliderControl
|
||||
label="Bands"
|
||||
max={4}
|
||||
min={1}
|
||||
onChange={(value) => onUpdate(buildWallFaceBandCountPatch(node, Math.round(value)))}
|
||||
precision={0}
|
||||
step={1}
|
||||
value={bandCount}
|
||||
/>
|
||||
{bandCount >= 2 && (
|
||||
<SliderControl
|
||||
label="Lower"
|
||||
max={metersToLinearUnit(wallHeightMeters, unit)}
|
||||
min={metersToLinearUnit(0, unit)}
|
||||
onChange={(value) =>
|
||||
updateBands({
|
||||
lowerHeight: linearControlValueToMeters(value, unit, {
|
||||
maxMeters: wallHeightMeters,
|
||||
minMeters: 0,
|
||||
}),
|
||||
})
|
||||
}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
unit={unitLabel}
|
||||
value={metersToLinearUnit(lowerHeight, unit)}
|
||||
/>
|
||||
)}
|
||||
{bandCount >= 3 && (
|
||||
<SliderControl
|
||||
label="Middle"
|
||||
max={metersToLinearUnit(Math.max(0, wallHeightMeters - lowerHeight), unit)}
|
||||
min={metersToLinearUnit(0, unit)}
|
||||
onChange={(value) =>
|
||||
updateBands({
|
||||
middleHeight: linearControlValueToMeters(value, unit, {
|
||||
maxMeters: Math.max(0, wallHeightMeters - lowerHeight),
|
||||
minMeters: 0,
|
||||
}),
|
||||
})
|
||||
}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
unit={unitLabel}
|
||||
value={metersToLinearUnit(middleHeight, unit)}
|
||||
/>
|
||||
)}
|
||||
{bandCount >= 4 && (
|
||||
<SliderControl
|
||||
label="Upper"
|
||||
max={metersToLinearUnit(Math.max(0, wallHeightMeters - lowerHeight - middleHeight), unit)}
|
||||
min={metersToLinearUnit(0, unit)}
|
||||
onChange={(value) =>
|
||||
updateBands({
|
||||
upperHeight: linearControlValueToMeters(value, unit, {
|
||||
maxMeters: Math.max(0, wallHeightMeters - lowerHeight - middleHeight),
|
||||
minMeters: 0,
|
||||
}),
|
||||
})
|
||||
}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
unit={unitLabel}
|
||||
value={metersToLinearUnit(upperHeight, unit)}
|
||||
/>
|
||||
)}
|
||||
</PanelSection>
|
||||
)
|
||||
}
|
||||
|
||||
function WallTrimSection({
|
||||
node,
|
||||
onUpdate,
|
||||
title,
|
||||
trimKey,
|
||||
trimValue,
|
||||
unit,
|
||||
unitLabel,
|
||||
wallHeightMeters,
|
||||
}: {
|
||||
node: WallNode
|
||||
onUpdate: (updates: Partial<WallNode>) => void
|
||||
title: string
|
||||
trimKey: WallTrimKey
|
||||
trimValue: NonNullable<WallNode['skirting']>
|
||||
unit: 'metric' | 'imperial'
|
||||
unitLabel: string
|
||||
wallHeightMeters: number
|
||||
}) {
|
||||
const updateTrim = (patch: Partial<NonNullable<WallNode['skirting']>>) =>
|
||||
onUpdate({
|
||||
[trimKey]: {
|
||||
...trimValue,
|
||||
...patch,
|
||||
},
|
||||
} as Partial<WallNode>)
|
||||
const profileOptions = WALL_TRIM_PROFILE_OPTIONS[trimKey]
|
||||
const selectedProfile = profileOptions.some((option) => option.value === trimValue.profile)
|
||||
? trimValue.profile
|
||||
: profileOptions[0]!.value
|
||||
|
||||
return (
|
||||
<PanelSection title={title}>
|
||||
<ActionGroup>
|
||||
<ActionButton
|
||||
label={trimValue.enabled ? `Hide ${title.toLowerCase()}` : `Show ${title.toLowerCase()}`}
|
||||
onClick={() => updateTrim({ enabled: !trimValue.enabled })}
|
||||
/>
|
||||
</ActionGroup>
|
||||
{trimValue.enabled && (
|
||||
<>
|
||||
<SegmentedControl
|
||||
onChange={(next) => updateTrim({ sides: next as any })}
|
||||
options={[
|
||||
{ label: 'Interior', value: 'interior' },
|
||||
{ label: 'Exterior', value: 'exterior' },
|
||||
{ label: 'Both', value: 'both' },
|
||||
]}
|
||||
value={trimValue.sides}
|
||||
/>
|
||||
<SegmentedControl
|
||||
onChange={(next) => updateTrim({ profile: next })}
|
||||
options={profileOptions}
|
||||
value={selectedProfile}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Height"
|
||||
max={metersToLinearUnit(Math.max(0.05, wallHeightMeters), unit)}
|
||||
min={metersToLinearUnit(0.01, unit)}
|
||||
onChange={(value) =>
|
||||
updateTrim({
|
||||
height: linearControlValueToMeters(value, unit, {
|
||||
maxMeters: Math.max(0.05, wallHeightMeters),
|
||||
minMeters: 0.01,
|
||||
}),
|
||||
})
|
||||
}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
unit={unitLabel}
|
||||
value={metersToLinearUnit(trimValue.height, unit)}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Proud"
|
||||
max={metersToLinearUnit(0.2, unit)}
|
||||
min={metersToLinearUnit(0.001, unit)}
|
||||
onChange={(value) =>
|
||||
updateTrim({
|
||||
proud: linearControlValueToMeters(value, unit, {
|
||||
maxMeters: 0.2,
|
||||
minMeters: 0.001,
|
||||
}),
|
||||
})
|
||||
}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
unit={unitLabel}
|
||||
value={metersToLinearUnit(trimValue.proud, unit)}
|
||||
/>
|
||||
{trimKey === 'chairRail' && (
|
||||
<SliderControl
|
||||
label="Offset"
|
||||
max={metersToLinearUnit(Math.max(0.05, wallHeightMeters - trimValue.height), unit)}
|
||||
min={metersToLinearUnit(0, unit)}
|
||||
onChange={(value) =>
|
||||
updateTrim({
|
||||
offsetY: linearControlValueToMeters(value, unit, {
|
||||
maxMeters: Math.max(0.05, wallHeightMeters - trimValue.height),
|
||||
minMeters: 0,
|
||||
}),
|
||||
})
|
||||
}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
unit={unitLabel}
|
||||
value={metersToLinearUnit(trimValue.offsetY ?? 0, unit)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PanelSection>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import { useRegistry, useScene, type WallNode } from '@pascal-app/core'
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
useRegistry,
|
||||
useScene,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { getVisibleWallMaterials, NodeRenderer, useNodeEvents, useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import type { Mesh } from 'three'
|
||||
import { createPlaceholderGeometry } from '../shared/placeholder-geometry'
|
||||
import { createWallExtraSlotMaterials, WallTreatments } from './treatments'
|
||||
|
||||
/**
|
||||
* Thin wall renderer.
|
||||
@@ -27,7 +34,6 @@ import { createPlaceholderGeometry } from '../shared/placeholder-geometry'
|
||||
*/
|
||||
const WallRenderer = ({ node }: { node: WallNode }) => {
|
||||
const ref = useRef<Mesh>(null!)
|
||||
// 3 groups map 1:1 to the wall's 3-material array (see getVisibleWallMaterials).
|
||||
const placeholderGeometry = useMemo(() => createPlaceholderGeometry(3), [])
|
||||
const collisionPlaceholderGeometry = useMemo(() => createPlaceholderGeometry(), [])
|
||||
|
||||
@@ -49,12 +55,20 @@ const WallRenderer = ({ node }: { node: WallNode }) => {
|
||||
const textures = useViewer((s) => s.textures)
|
||||
const colorPreset = useViewer((s) => s.colorPreset)
|
||||
const sceneTheme = useViewer((s) => s.sceneTheme)
|
||||
const sceneNodes = useScene((state) => state.nodes)
|
||||
const childNodes = useMemo(
|
||||
() =>
|
||||
(node.children ?? [])
|
||||
.map((childId) => sceneNodes[childId as AnyNodeId])
|
||||
.filter((child): child is AnyNode => child !== undefined),
|
||||
[node.children, sceneNodes],
|
||||
)
|
||||
// Subscribe to the scene-material palette so editing a `scene:` material a
|
||||
// wall slot references re-renders the wall live (the wall-system geometry
|
||||
// dirty loop never fires for a material-only edit). `getMaterialsForWall`'s
|
||||
// content hash keeps unaffected walls on their cached materials.
|
||||
const sceneMaterials = useScene((s) => s.materials)
|
||||
const material = getVisibleWallMaterials(
|
||||
const baseMaterials = getVisibleWallMaterials(
|
||||
node,
|
||||
shading,
|
||||
textures,
|
||||
@@ -62,12 +76,24 @@ const WallRenderer = ({ node }: { node: WallNode }) => {
|
||||
sceneTheme,
|
||||
sceneMaterials,
|
||||
)
|
||||
const extraMaterials = useMemo(
|
||||
() => createWallExtraSlotMaterials(node, shading, sceneMaterials),
|
||||
[node, sceneMaterials, shading],
|
||||
)
|
||||
useEffect(
|
||||
() => () => {
|
||||
const baseSet = new Set(baseMaterials)
|
||||
const owned = new Set(Object.values(extraMaterials).filter((entry) => !baseSet.has(entry)))
|
||||
for (const entry of owned) entry.dispose()
|
||||
},
|
||||
[baseMaterials, extraMaterials],
|
||||
)
|
||||
|
||||
return (
|
||||
<mesh
|
||||
castShadow
|
||||
geometry={placeholderGeometry}
|
||||
material={material}
|
||||
material={baseMaterials}
|
||||
receiveShadow
|
||||
ref={ref}
|
||||
visible={node.visible}
|
||||
@@ -79,6 +105,8 @@ const WallRenderer = ({ node }: { node: WallNode }) => {
|
||||
{...handlers}
|
||||
/>
|
||||
|
||||
<WallTreatments childrenNodes={childNodes} materials={extraMaterials} node={node} />
|
||||
|
||||
{(node.children ?? []).map((childId) => (
|
||||
<NodeRenderer key={`${node.id}:${childId}`} nodeId={childId} />
|
||||
))}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type SlotDeclaration, WALL_SLOT_DEFAULT } from '@pascal-app/core'
|
||||
import { type SlotDeclaration, WALL_SURFACE_SLOT_DEFAULTS } from '@pascal-app/core'
|
||||
|
||||
/**
|
||||
* A wall exposes two paintable faces — interior + exterior. Painting writes
|
||||
@@ -9,7 +9,77 @@ import { type SlotDeclaration, WALL_SLOT_DEFAULT } from '@pascal-app/core'
|
||||
*/
|
||||
export function wallSlots(): SlotDeclaration[] {
|
||||
return [
|
||||
{ slotId: 'interior', label: 'Interior', default: WALL_SLOT_DEFAULT.interior },
|
||||
{ slotId: 'exterior', label: 'Exterior', default: WALL_SLOT_DEFAULT.exterior },
|
||||
{ slotId: 'interior', label: 'Interior', default: WALL_SURFACE_SLOT_DEFAULTS.interior },
|
||||
{ slotId: 'exterior', label: 'Exterior', default: WALL_SURFACE_SLOT_DEFAULTS.exterior },
|
||||
{
|
||||
slotId: 'lowerInterior',
|
||||
label: 'Lower band (interior)',
|
||||
default: WALL_SURFACE_SLOT_DEFAULTS.lowerInterior,
|
||||
},
|
||||
{
|
||||
slotId: 'middleInterior',
|
||||
label: 'Middle band (interior)',
|
||||
default: WALL_SURFACE_SLOT_DEFAULTS.middleInterior,
|
||||
},
|
||||
{
|
||||
slotId: 'upperInterior',
|
||||
label: 'Upper band (interior)',
|
||||
default: WALL_SURFACE_SLOT_DEFAULTS.upperInterior,
|
||||
},
|
||||
{
|
||||
slotId: 'topInterior',
|
||||
label: 'Top band (interior)',
|
||||
default: WALL_SURFACE_SLOT_DEFAULTS.topInterior,
|
||||
},
|
||||
{
|
||||
slotId: 'lowerExterior',
|
||||
label: 'Lower band (exterior)',
|
||||
default: WALL_SURFACE_SLOT_DEFAULTS.lowerExterior,
|
||||
},
|
||||
{
|
||||
slotId: 'middleExterior',
|
||||
label: 'Middle band (exterior)',
|
||||
default: WALL_SURFACE_SLOT_DEFAULTS.middleExterior,
|
||||
},
|
||||
{
|
||||
slotId: 'upperExterior',
|
||||
label: 'Upper band (exterior)',
|
||||
default: WALL_SURFACE_SLOT_DEFAULTS.upperExterior,
|
||||
},
|
||||
{
|
||||
slotId: 'topExterior',
|
||||
label: 'Top band (exterior)',
|
||||
default: WALL_SURFACE_SLOT_DEFAULTS.topExterior,
|
||||
},
|
||||
{
|
||||
slotId: 'skirtingInterior',
|
||||
label: 'Skirting (interior)',
|
||||
default: WALL_SURFACE_SLOT_DEFAULTS.skirtingInterior,
|
||||
},
|
||||
{
|
||||
slotId: 'skirtingExterior',
|
||||
label: 'Skirting (exterior)',
|
||||
default: WALL_SURFACE_SLOT_DEFAULTS.skirtingExterior,
|
||||
},
|
||||
{
|
||||
slotId: 'crownInterior',
|
||||
label: 'Crown (interior)',
|
||||
default: WALL_SURFACE_SLOT_DEFAULTS.crownInterior,
|
||||
},
|
||||
{
|
||||
slotId: 'crownExterior',
|
||||
label: 'Crown (exterior)',
|
||||
default: WALL_SURFACE_SLOT_DEFAULTS.crownExterior,
|
||||
},
|
||||
{
|
||||
slotId: 'chairRailInterior',
|
||||
label: 'Chair rail (interior)',
|
||||
default: WALL_SURFACE_SLOT_DEFAULTS.chairRailInterior,
|
||||
},
|
||||
{
|
||||
slotId: 'chairRailExterior',
|
||||
label: 'Chair rail (exterior)',
|
||||
default: WALL_SURFACE_SLOT_DEFAULTS.chairRailExterior,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,613 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
getWallCurveFrameAt,
|
||||
getWallThickness,
|
||||
isCurvedWall,
|
||||
type SceneMaterial,
|
||||
type SceneMaterialId,
|
||||
WALL_CHAIR_RAIL_DEFAULT,
|
||||
WALL_CROWN_DEFAULT,
|
||||
WALL_SKIRTING_DEFAULT,
|
||||
WALL_SURFACE_SLOT_DEFAULTS,
|
||||
type WallNode,
|
||||
type WallSurfaceSlotId,
|
||||
type WallTrimConfig,
|
||||
type WallTrimProfile,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
baseMaterial,
|
||||
createMaterialFromPresetRef,
|
||||
type RenderShading,
|
||||
resolveMaterialRef,
|
||||
} from '@pascal-app/viewer'
|
||||
import { memo, useEffect, useMemo } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { mergeGeometries as mergeBufferGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
||||
|
||||
const CURVE_SEGMENTS = 24
|
||||
const MIN_SLICE_PROUD = 0.0005
|
||||
const EPS = 1e-6
|
||||
|
||||
type OpeningLike = {
|
||||
type: string
|
||||
width?: number
|
||||
height?: number
|
||||
position?: [number, number, number]
|
||||
}
|
||||
|
||||
type TrimKind = 'skirting' | 'crown' | 'chairRail'
|
||||
type WallSide = 'interior' | 'exterior'
|
||||
type SceneMaterials = Record<SceneMaterialId, SceneMaterial>
|
||||
type Point2 = { x: number; z: number }
|
||||
type WallTreatmentSlotId =
|
||||
| 'skirtingInterior'
|
||||
| 'skirtingExterior'
|
||||
| 'crownInterior'
|
||||
| 'crownExterior'
|
||||
| 'chairRailInterior'
|
||||
| 'chairRailExterior'
|
||||
type TrimProfileDefinition = {
|
||||
samples: number
|
||||
proudAt: (t: number) => number
|
||||
}
|
||||
|
||||
function clamp01(value: number) {
|
||||
return Math.max(0, Math.min(1, value))
|
||||
}
|
||||
|
||||
function eased(value: number) {
|
||||
const t = clamp01(value)
|
||||
return t * t * (3 - 2 * t)
|
||||
}
|
||||
|
||||
const TRIM_PROFILES: Record<TrimKind, Partial<Record<WallTrimProfile, TrimProfileDefinition>>> = {
|
||||
skirting: {
|
||||
flat: {
|
||||
samples: 8,
|
||||
proudAt: (t) => (t < 0.72 ? 0.62 : 0.9),
|
||||
},
|
||||
bevel: {
|
||||
samples: 10,
|
||||
proudAt: (t) => 0.48 + 0.42 * eased(t),
|
||||
},
|
||||
triangle: {
|
||||
samples: 10,
|
||||
proudAt: (t) => 0.32 + 0.68 * (1 - t),
|
||||
},
|
||||
cove: {
|
||||
samples: 12,
|
||||
proudAt: (t) => 0.45 + 0.42 * Math.sin(t * Math.PI * 0.5),
|
||||
},
|
||||
bullnose: {
|
||||
samples: 12,
|
||||
proudAt: (t) => 0.35 + 0.65 * Math.sin(Math.PI * t),
|
||||
},
|
||||
'base-modern': {
|
||||
samples: 10,
|
||||
proudAt: (t) => {
|
||||
if (t < 0.16) return 0.85
|
||||
if (t < 0.72) return 0.58
|
||||
if (t < 0.9) return 1
|
||||
return 0.62
|
||||
},
|
||||
},
|
||||
'base-colonial': {
|
||||
samples: 14,
|
||||
proudAt: (t) => {
|
||||
if (t < 0.16) return 0.82
|
||||
if (t < 0.55) return 0.52
|
||||
const capT = (t - 0.55) / 0.45
|
||||
return 0.58 + 0.4 * Math.sin(capT * Math.PI)
|
||||
},
|
||||
},
|
||||
'base-shoe': {
|
||||
samples: 14,
|
||||
proudAt: (t) => {
|
||||
const quarterRound = Math.sqrt(Math.max(0, 1 - t * t))
|
||||
return 0.28 + 0.72 * quarterRound
|
||||
},
|
||||
},
|
||||
'base-ogee': {
|
||||
samples: 16,
|
||||
proudAt: (t) => {
|
||||
const ogee = 0.5 - 0.5 * Math.cos(Math.PI * t)
|
||||
const bead = 0.16 * Math.sin(2 * Math.PI * t)
|
||||
return clamp01(0.42 + 0.5 * ogee + bead)
|
||||
},
|
||||
},
|
||||
},
|
||||
crown: {
|
||||
flat: {
|
||||
samples: 8,
|
||||
proudAt: (t) => (t < 0.2 ? 0.52 : t < 0.82 ? 0.78 : 1),
|
||||
},
|
||||
bevel: {
|
||||
samples: 10,
|
||||
proudAt: (t) => 0.45 + 0.55 * eased(t),
|
||||
},
|
||||
triangle: {
|
||||
samples: 10,
|
||||
proudAt: (t) => 0.35 + 0.65 * t,
|
||||
},
|
||||
cove: {
|
||||
samples: 14,
|
||||
proudAt: (t) => 0.46 + 0.45 * Math.sin(t * Math.PI * 0.5),
|
||||
},
|
||||
bullnose: {
|
||||
samples: 14,
|
||||
proudAt: (t) => 0.38 + 0.62 * Math.sin(Math.PI * t * 0.5),
|
||||
},
|
||||
'crown-cove': {
|
||||
samples: 16,
|
||||
proudAt: (t) => {
|
||||
if (t < 0.12) return 0.48
|
||||
if (t > 0.9) return 1
|
||||
return 0.46 + 0.48 * Math.sin(((t - 0.12) / 0.78) * Math.PI * 0.5)
|
||||
},
|
||||
},
|
||||
'crown-ogee': {
|
||||
samples: 18,
|
||||
proudAt: (t) => {
|
||||
const sCurve = 0.5 - 0.5 * Math.cos(Math.PI * t)
|
||||
const reverse = 0.18 * Math.sin(2 * Math.PI * (t - 0.12))
|
||||
return clamp01(0.42 + 0.55 * sCurve + reverse)
|
||||
},
|
||||
},
|
||||
'crown-craftsman': {
|
||||
samples: 10,
|
||||
proudAt: (t) => {
|
||||
if (t < 0.16) return 0.48
|
||||
if (t < 0.4) return 0.88
|
||||
if (t < 0.78) return 0.64
|
||||
return 1
|
||||
},
|
||||
},
|
||||
'crown-layered': {
|
||||
samples: 14,
|
||||
proudAt: (t) => {
|
||||
if (t < 0.12) return 0.46
|
||||
if (t < 0.28) return 0.82
|
||||
if (t < 0.48) return 0.56
|
||||
if (t < 0.72) return 0.9
|
||||
return 1
|
||||
},
|
||||
},
|
||||
},
|
||||
chairRail: {
|
||||
flat: {
|
||||
samples: 8,
|
||||
proudAt: (t) => (t < 0.18 || t > 0.82 ? 0.58 : 0.95),
|
||||
},
|
||||
bevel: {
|
||||
samples: 10,
|
||||
proudAt: (t) => 0.45 + 0.45 * Math.sin(Math.PI * t),
|
||||
},
|
||||
triangle: {
|
||||
samples: 10,
|
||||
proudAt: (t) => 0.35 + 0.65 * (1 - Math.abs(2 * t - 1)),
|
||||
},
|
||||
cove: {
|
||||
samples: 12,
|
||||
proudAt: (t) => 0.42 + 0.42 * Math.sin(Math.PI * t),
|
||||
},
|
||||
bullnose: {
|
||||
samples: 14,
|
||||
proudAt: (t) => 0.34 + 0.66 * Math.sin(Math.PI * t),
|
||||
},
|
||||
'rail-rounded': {
|
||||
samples: 14,
|
||||
proudAt: (t) => 0.34 + 0.66 * Math.sin(Math.PI * t),
|
||||
},
|
||||
'rail-ogee': {
|
||||
samples: 16,
|
||||
proudAt: (t) => {
|
||||
const center = Math.sin(Math.PI * t)
|
||||
const twist = 0.14 * Math.sin(2 * Math.PI * t)
|
||||
return clamp01(0.36 + 0.58 * center + twist)
|
||||
},
|
||||
},
|
||||
'rail-picture': {
|
||||
samples: 12,
|
||||
proudAt: (t) => {
|
||||
if (t < 0.18) return 0.48
|
||||
if (t < 0.4) return 0.92
|
||||
if (t < 0.74) return 0.58
|
||||
return 1
|
||||
},
|
||||
},
|
||||
'rail-stepped': {
|
||||
samples: 10,
|
||||
proudAt: (t) => {
|
||||
if (t < 0.22) return 0.55
|
||||
if (t < 0.78) return 1
|
||||
return 0.62
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const TRIM_KIND_CONFIG: Record<
|
||||
TrimKind,
|
||||
{
|
||||
defaultConfig: WallTrimConfig
|
||||
slots: Record<WallSide, WallTreatmentSlotId>
|
||||
}
|
||||
> = {
|
||||
skirting: {
|
||||
defaultConfig: WALL_SKIRTING_DEFAULT,
|
||||
slots: { interior: 'skirtingInterior', exterior: 'skirtingExterior' },
|
||||
},
|
||||
crown: {
|
||||
defaultConfig: WALL_CROWN_DEFAULT,
|
||||
slots: { interior: 'crownInterior', exterior: 'crownExterior' },
|
||||
},
|
||||
chairRail: {
|
||||
defaultConfig: WALL_CHAIR_RAIL_DEFAULT,
|
||||
slots: { interior: 'chairRailInterior', exterior: 'chairRailExterior' },
|
||||
},
|
||||
}
|
||||
|
||||
function resolveTrimProfile(kind: TrimKind, trim: WallTrimConfig) {
|
||||
const defaultProfile = TRIM_KIND_CONFIG[kind].defaultConfig.profile
|
||||
return (
|
||||
TRIM_PROFILES[kind][trim.profile] ??
|
||||
TRIM_PROFILES[kind][defaultProfile] ??
|
||||
TRIM_PROFILES[kind].flat
|
||||
)
|
||||
}
|
||||
|
||||
function resolveTreatmentSideSign(node: WallNode, side: WallSide) {
|
||||
if (side === 'interior') {
|
||||
if (node.frontSide === 'interior') return 1
|
||||
if (node.backSide === 'interior') return -1
|
||||
return 1
|
||||
}
|
||||
if (node.frontSide === 'exterior') return 1
|
||||
if (node.backSide === 'exterior') return -1
|
||||
return -1
|
||||
}
|
||||
|
||||
function wallToLocalTransform(node: WallNode) {
|
||||
const dx = node.end[0] - node.start[0]
|
||||
const dz = node.end[1] - node.start[1]
|
||||
const angle = Math.atan2(dz, dx)
|
||||
const cosA = Math.cos(-angle)
|
||||
const sinA = Math.sin(-angle)
|
||||
return (worldX: number, worldZ: number): Point2 => {
|
||||
const px = worldX - node.start[0]
|
||||
const pz = worldZ - node.start[1]
|
||||
return {
|
||||
x: px * cosA - pz * sinA,
|
||||
z: px * sinA + pz * cosA,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildSidePolyline(node: WallNode, side: WallSide, offset: number): Point2[] {
|
||||
const sideSign = resolveTreatmentSideSign(node, side)
|
||||
const toLocal = wallToLocalTransform(node)
|
||||
const sampleCount = isCurvedWall(node) ? CURVE_SEGMENTS : 1
|
||||
const points: Point2[] = []
|
||||
|
||||
for (let index = 0; index <= sampleCount; index += 1) {
|
||||
const frame = getWallCurveFrameAt(node, index / sampleCount)
|
||||
const worldX = frame.point.x + frame.normal.x * offset * sideSign
|
||||
const worldZ = frame.point.y + frame.normal.y * offset * sideSign
|
||||
points.push(toLocal(worldX, worldZ))
|
||||
}
|
||||
|
||||
return points
|
||||
}
|
||||
|
||||
function clipPolyline(points: Point2[], x0: number, x1: number): Point2[] {
|
||||
if (points.length < 2 || x1 - x0 <= EPS) return []
|
||||
const out: Point2[] = []
|
||||
for (let index = 0; index < points.length - 1; index += 1) {
|
||||
const a = points[index]
|
||||
const b = points[index + 1]
|
||||
if (!(a && b)) continue
|
||||
const minX = Math.min(a.x, b.x)
|
||||
const maxX = Math.max(a.x, b.x)
|
||||
if (maxX < x0 - EPS || minX > x1 + EPS) continue
|
||||
|
||||
const pushPointAt = (x: number) => {
|
||||
if (Math.abs(b.x - a.x) <= EPS) {
|
||||
return { x, z: a.z }
|
||||
}
|
||||
const t = (x - a.x) / (b.x - a.x)
|
||||
return {
|
||||
x,
|
||||
z: a.z + (b.z - a.z) * t,
|
||||
}
|
||||
}
|
||||
|
||||
const start = minX < x0 ? pushPointAt(x0) : a
|
||||
const end = maxX > x1 ? pushPointAt(x1) : b
|
||||
if (
|
||||
out.length === 0 ||
|
||||
Math.hypot(out[out.length - 1]!.x - start.x, out[out.length - 1]!.z - start.z) > EPS
|
||||
) {
|
||||
out.push(start)
|
||||
}
|
||||
out.push(end)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function subtractOpeningRanges(ranges: Array<[number, number]>, openings: Array<[number, number]>) {
|
||||
let next = ranges.slice()
|
||||
for (const [gap0, gap1] of openings) {
|
||||
const updated: Array<[number, number]> = []
|
||||
for (const [a, b] of next) {
|
||||
const start = Math.max(a, gap0)
|
||||
const end = Math.min(b, gap1)
|
||||
if (end - start <= EPS) {
|
||||
updated.push([a, b])
|
||||
continue
|
||||
}
|
||||
if (start - a > EPS) updated.push([a, start])
|
||||
if (b - end > EPS) updated.push([end, b])
|
||||
}
|
||||
next = updated
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function trimOpeningRanges(
|
||||
node: WallNode,
|
||||
childrenNodes: OpeningLike[],
|
||||
yBottom: number,
|
||||
height: number,
|
||||
) {
|
||||
const yTop = yBottom + height
|
||||
return childrenNodes
|
||||
.filter((child) => child.type === 'door' || child.type === 'window')
|
||||
.flatMap((child) => {
|
||||
const width = child.width ?? 0
|
||||
const childHeight = child.height ?? 0
|
||||
const position = child.position ?? [0, 0, 0]
|
||||
const childBottom = position[1] - childHeight / 2
|
||||
const childTop = childBottom + childHeight
|
||||
if (childTop <= yBottom + EPS || childBottom >= yTop - EPS) return []
|
||||
return [[position[0] - width / 2, position[0] + width / 2] as [number, number]]
|
||||
})
|
||||
}
|
||||
|
||||
function buildPlanPolygon(outer: Point2[], inner: Point2[]) {
|
||||
if (outer.length < 2 || inner.length < 2) return null
|
||||
const shape = new THREE.Shape()
|
||||
shape.moveTo(outer[0]!.x, -outer[0]!.z)
|
||||
for (let index = 1; index < outer.length; index += 1) {
|
||||
shape.lineTo(outer[index]!.x, -outer[index]!.z)
|
||||
}
|
||||
for (let index = inner.length - 1; index >= 0; index -= 1) {
|
||||
shape.lineTo(inner[index]!.x, -inner[index]!.z)
|
||||
}
|
||||
shape.closePath()
|
||||
return shape
|
||||
}
|
||||
|
||||
function applyWorldScaleUvs(geometry: THREE.BufferGeometry) {
|
||||
const position = geometry.getAttribute('position') as THREE.BufferAttribute | undefined
|
||||
if (!position) return
|
||||
const normal = geometry.getAttribute('normal') as THREE.BufferAttribute | undefined
|
||||
const uv = new Float32Array(position.count * 2)
|
||||
|
||||
for (let index = 0; index < position.count; index += 1) {
|
||||
const x = position.getX(index)
|
||||
const y = position.getY(index)
|
||||
const z = position.getZ(index)
|
||||
const nx = Math.abs(normal?.getX(index) ?? 0)
|
||||
const ny = Math.abs(normal?.getY(index) ?? 0)
|
||||
const nz = Math.abs(normal?.getZ(index) ?? 0)
|
||||
const offset = index * 2
|
||||
|
||||
if (ny >= nx && ny >= nz) {
|
||||
uv[offset] = x
|
||||
uv[offset + 1] = z
|
||||
} else if (nz >= nx) {
|
||||
uv[offset] = x
|
||||
uv[offset + 1] = y
|
||||
} else {
|
||||
uv[offset] = z
|
||||
uv[offset + 1] = y
|
||||
}
|
||||
}
|
||||
|
||||
geometry.setAttribute('uv', new THREE.BufferAttribute(uv, 2))
|
||||
}
|
||||
|
||||
function buildTrimSliceGeometry(
|
||||
outer: Point2[],
|
||||
inner: Point2[],
|
||||
extrudeHeight: number,
|
||||
translateY: number,
|
||||
) {
|
||||
const shape = buildPlanPolygon(outer, inner)
|
||||
if (!shape) return null
|
||||
const geometry = new THREE.ExtrudeGeometry(shape, {
|
||||
depth: extrudeHeight,
|
||||
bevelEnabled: false,
|
||||
steps: 1,
|
||||
curveSegments: 1,
|
||||
})
|
||||
geometry.rotateX(-Math.PI / 2)
|
||||
geometry.translate(0, translateY, 0)
|
||||
geometry.computeVertexNormals()
|
||||
applyWorldScaleUvs(geometry)
|
||||
return geometry
|
||||
}
|
||||
|
||||
function mergeGeometries(geometries: THREE.BufferGeometry[]) {
|
||||
if (geometries.length === 0) return null
|
||||
const merged = mergeBufferGeometries(geometries, false)
|
||||
if (merged) return merged
|
||||
return null
|
||||
}
|
||||
|
||||
function buildTrimGeometry(
|
||||
node: WallNode,
|
||||
side: WallSide,
|
||||
trim: WallTrimConfig,
|
||||
kind: TrimKind,
|
||||
childrenNodes: OpeningLike[],
|
||||
) {
|
||||
const wallHeight = node.height ?? 2.5
|
||||
const height = trim.height
|
||||
const yBottom =
|
||||
kind === 'crown'
|
||||
? Math.max(0, wallHeight - height)
|
||||
: kind === 'chairRail'
|
||||
? Math.max(
|
||||
0,
|
||||
Math.min(wallHeight - height, trim.offsetY ?? WALL_CHAIR_RAIL_DEFAULT.offsetY ?? 0.9),
|
||||
)
|
||||
: 0
|
||||
|
||||
const thickness = getWallThickness(node)
|
||||
const inner = buildSidePolyline(node, side, thickness / 2)
|
||||
if (inner.length < 2) return null
|
||||
|
||||
const openingRanges = trimOpeningRanges(node, childrenNodes, yBottom, height)
|
||||
const fullRanges: Array<[number, number]> = [[inner[0]!.x, inner[inner.length - 1]!.x]]
|
||||
const runs = subtractOpeningRanges(fullRanges, openingRanges)
|
||||
if (runs.length === 0) return null
|
||||
|
||||
const slices: THREE.BufferGeometry[] = []
|
||||
const profile = resolveTrimProfile(kind, trim)
|
||||
if (!profile) return null
|
||||
const sliceHeight = height / profile.samples
|
||||
|
||||
for (const [runStart, runEnd] of runs) {
|
||||
const innerRun = clipPolyline(inner, runStart, runEnd)
|
||||
if (innerRun.length < 2) continue
|
||||
for (let index = 0; index < profile.samples; index += 1) {
|
||||
const t = (index + 0.5) / profile.samples
|
||||
const proud = Math.max(MIN_SLICE_PROUD, trim.proud * profile.proudAt(t))
|
||||
const outerRun = buildSidePolyline(node, side, thickness / 2 + proud)
|
||||
const outerClipped = clipPolyline(outerRun, runStart, runEnd)
|
||||
if (outerClipped.length < 2) continue
|
||||
const slice = buildTrimSliceGeometry(
|
||||
outerClipped,
|
||||
innerRun,
|
||||
sliceHeight,
|
||||
yBottom + index * sliceHeight,
|
||||
)
|
||||
if (slice) slices.push(slice)
|
||||
}
|
||||
}
|
||||
|
||||
if (slices.length === 0) return null
|
||||
const merged = mergeGeometries(slices)
|
||||
for (const slice of slices) slice.dispose()
|
||||
return merged
|
||||
}
|
||||
|
||||
function resolveWallSlotMaterial(
|
||||
node: WallNode,
|
||||
slotId: WallSurfaceSlotId,
|
||||
shading: RenderShading,
|
||||
sceneMaterials: SceneMaterials,
|
||||
) {
|
||||
const ref = node.slots?.[slotId]
|
||||
if (ref) {
|
||||
return resolveMaterialRef(ref, sceneMaterials, shading) ?? baseMaterial(shading)
|
||||
}
|
||||
return (
|
||||
createMaterialFromPresetRef(WALL_SURFACE_SLOT_DEFAULTS[slotId], shading) ??
|
||||
baseMaterial(shading)
|
||||
)
|
||||
}
|
||||
|
||||
export function createWallExtraSlotMaterials(
|
||||
node: WallNode,
|
||||
shading: RenderShading,
|
||||
sceneMaterials: SceneMaterials,
|
||||
) {
|
||||
return {
|
||||
skirtingInterior: resolveWallSlotMaterial(node, 'skirtingInterior', shading, sceneMaterials),
|
||||
skirtingExterior: resolveWallSlotMaterial(node, 'skirtingExterior', shading, sceneMaterials),
|
||||
crownInterior: resolveWallSlotMaterial(node, 'crownInterior', shading, sceneMaterials),
|
||||
crownExterior: resolveWallSlotMaterial(node, 'crownExterior', shading, sceneMaterials),
|
||||
chairRailInterior: resolveWallSlotMaterial(node, 'chairRailInterior', shading, sceneMaterials),
|
||||
chairRailExterior: resolveWallSlotMaterial(node, 'chairRailExterior', shading, sceneMaterials),
|
||||
} satisfies Record<WallTreatmentSlotId, THREE.Material>
|
||||
}
|
||||
|
||||
export const WallTreatments = memo(function WallTreatments({
|
||||
node,
|
||||
childrenNodes,
|
||||
materials,
|
||||
}: {
|
||||
node: WallNode
|
||||
childrenNodes: OpeningLike[]
|
||||
materials: Record<WallTreatmentSlotId, THREE.Material>
|
||||
}) {
|
||||
const fallbackMaterial =
|
||||
materials.skirtingInterior ??
|
||||
materials.skirtingExterior ??
|
||||
materials.crownInterior ??
|
||||
materials.crownExterior ??
|
||||
materials.chairRailInterior ??
|
||||
materials.chairRailExterior
|
||||
|
||||
const trimEntries = useMemo(() => {
|
||||
const out: Array<{
|
||||
key: string
|
||||
slotId: WallTreatmentSlotId
|
||||
geometry: THREE.BufferGeometry
|
||||
material: THREE.Material
|
||||
}> = []
|
||||
|
||||
const configs: Array<[TrimKind, WallTrimConfig | undefined]> = [
|
||||
['skirting', node.skirting],
|
||||
['crown', node.crown],
|
||||
['chairRail', node.chairRail],
|
||||
]
|
||||
|
||||
for (const [kind, rawConfig] of configs) {
|
||||
const trim = { ...TRIM_KIND_CONFIG[kind].defaultConfig, ...(rawConfig ?? {}) }
|
||||
if (!trim.enabled) continue
|
||||
const sides =
|
||||
trim.sides === 'both'
|
||||
? (['interior', 'exterior'] as WallSide[])
|
||||
: ([trim.sides] as WallSide[])
|
||||
for (const side of sides) {
|
||||
const geometry = buildTrimGeometry(node, side, trim, kind, childrenNodes)
|
||||
if (!geometry) continue
|
||||
const slotId = TRIM_KIND_CONFIG[kind].slots[side]
|
||||
out.push({
|
||||
key: `${kind}-${side}`,
|
||||
slotId,
|
||||
geometry,
|
||||
material: materials[slotId] ?? fallbackMaterial,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}, [childrenNodes, fallbackMaterial, materials, node])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
for (const entry of trimEntries) entry.geometry.dispose()
|
||||
},
|
||||
[trimEntries],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{trimEntries.map((entry) => (
|
||||
<mesh
|
||||
castShadow
|
||||
geometry={entry.geometry}
|
||||
key={entry.key}
|
||||
material={entry.material}
|
||||
receiveShadow
|
||||
userData={{ slotId: entry.slotId }}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user