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
@@ -1,19 +1,23 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNodeDefinition, nodeRegistry } from '@pascal-app/core'
|
||||
import { type AnyNodeDefinition, createSceneApi, nodeRegistry, useScene } from '@pascal-app/core'
|
||||
import { type ComponentType, lazy, Suspense, useMemo } from 'react'
|
||||
|
||||
const DEFAULT_PRIORITY = 5
|
||||
|
||||
// Cache lazy components keyed by the module-loader function so React.lazy
|
||||
// isn't re-invoked across renders.
|
||||
const lazyCache = new WeakMap<() => Promise<unknown>, ComponentType>()
|
||||
type RegisteredSystemProps = {
|
||||
sceneApi: ReturnType<typeof createSceneApi>
|
||||
}
|
||||
|
||||
function loadSystem(def: AnyNodeDefinition): ComponentType | null {
|
||||
const lazyCache = new WeakMap<() => Promise<unknown>, ComponentType<RegisteredSystemProps>>()
|
||||
|
||||
function loadSystem(def: AnyNodeDefinition): ComponentType<RegisteredSystemProps> | null {
|
||||
if (!def.system) return null
|
||||
const cached = lazyCache.get(def.system.module)
|
||||
if (cached) return cached
|
||||
const Comp = lazy(def.system.module as () => Promise<{ default: ComponentType }>)
|
||||
const Comp = lazy(def.system.module)
|
||||
lazyCache.set(def.system.module, Comp)
|
||||
return Comp
|
||||
}
|
||||
@@ -29,6 +33,7 @@ function loadSystem(def: AnyNodeDefinition): ComponentType | null {
|
||||
* guard added to each legacy system.
|
||||
*/
|
||||
export function RegisteredSystems() {
|
||||
const sceneApi = useMemo(() => createSceneApi(useScene), [])
|
||||
const entries = useMemo(() => {
|
||||
return Array.from(nodeRegistry.entries())
|
||||
.filter(([, def]) => def.system != null)
|
||||
@@ -46,7 +51,7 @@ export function RegisteredSystems() {
|
||||
{entries.map(([kind, def]) => {
|
||||
const Comp = loadSystem(def)
|
||||
if (!Comp) return null
|
||||
return <Comp key={`registered-system:${kind}`} />
|
||||
return <Comp key={`registered-system:${kind}`} sceneApi={sceneApi} />
|
||||
})}
|
||||
</Suspense>
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type LevelNode,
|
||||
type NodeEvent,
|
||||
pointInPolygon,
|
||||
resolveSelectionProxyId,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
type WallNode,
|
||||
@@ -184,20 +185,20 @@ const isNodeInZone = (node: AnyNode, levelId: string, zoneId: string): boolean =
|
||||
const getStrategy = (): SelectionStrategy | null => {
|
||||
const { buildingId, levelId, zoneId } = useViewer.getState().selection
|
||||
|
||||
const computeNextIds = (node: AnyNode, selectedIds: string[], event?: any): string[] => {
|
||||
const computeNextIds = (nodeId: string, selectedIds: string[], event?: any): string[] => {
|
||||
const isMeta = event?.metaKey || event?.nativeEvent?.metaKey
|
||||
const isCtrl = event?.ctrlKey || event?.nativeEvent?.ctrlKey
|
||||
// Shift toggles like Cmd/Ctrl — same convention as the 2D floorplan layer.
|
||||
// Shift toggles membership like Cmd/Ctrl.
|
||||
const isShift = event?.shiftKey || event?.nativeEvent?.shiftKey
|
||||
|
||||
if (isMeta || isCtrl || isShift) {
|
||||
if (selectedIds.includes(node.id)) {
|
||||
return selectedIds.filter((id) => id !== node.id)
|
||||
if (selectedIds.includes(nodeId)) {
|
||||
return selectedIds.filter((id) => id !== nodeId)
|
||||
}
|
||||
return [...selectedIds, node.id]
|
||||
return [...selectedIds, nodeId]
|
||||
}
|
||||
|
||||
return [node.id]
|
||||
return [nodeId]
|
||||
}
|
||||
|
||||
// No building selected -> can select buildings
|
||||
@@ -266,9 +267,13 @@ const getStrategy = (): SelectionStrategy | null => {
|
||||
}
|
||||
|
||||
const { selectedIds } = useViewer.getState().selection
|
||||
const proxyId = resolveSelectionProxyId(
|
||||
nodeToSelect,
|
||||
useScene.getState().nodes as Record<string, AnyNode | undefined>,
|
||||
)
|
||||
useViewer
|
||||
.getState()
|
||||
.setSelection({ selectedIds: computeNextIds(nodeToSelect, selectedIds, nativeEvent) })
|
||||
.setSelection({ selectedIds: computeNextIds(proxyId, selectedIds, nativeEvent) })
|
||||
},
|
||||
handleDeselect: () => {
|
||||
const { selectedIds } = useViewer.getState().selection
|
||||
@@ -317,7 +322,12 @@ export const SelectionManager = () => {
|
||||
useViewer.setState({ hoveredId: null })
|
||||
return
|
||||
}
|
||||
useViewer.setState({ hoveredId: event.node.id })
|
||||
useViewer.setState({
|
||||
hoveredId: resolveSelectionProxyId(
|
||||
event.node,
|
||||
useScene.getState().nodes as Record<string, AnyNode | undefined>,
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,7 +337,13 @@ export const SelectionManager = () => {
|
||||
if (event.node.type === 'ceiling') return
|
||||
if (strategy.isValid(event.node)) {
|
||||
event.stopPropagation()
|
||||
useViewer.setState({ hoveredId: null })
|
||||
const targetId = resolveSelectionProxyId(
|
||||
event.node,
|
||||
useScene.getState().nodes as Record<string, AnyNode | undefined>,
|
||||
)
|
||||
if (useViewer.getState().hoveredId === targetId) {
|
||||
useViewer.setState({ hoveredId: null })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ export { useAssetUrl } from './hooks/use-asset-url'
|
||||
export { useGLTFKTX2 } from './hooks/use-gltf-ktx2'
|
||||
export { useNodeEvents } from './hooks/use-node-events'
|
||||
export { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url'
|
||||
export { applyWorldScaleBoxUVs } from './lib/box-uv'
|
||||
// CSG primitives — used by chimney's roof-trim and other kinds whose
|
||||
// geometry subtracts pieces against their host. Lives in viewer
|
||||
// because three-bvh-csg / three-mesh-bvh are viewer-only deps.
|
||||
|
||||
@@ -89,6 +89,7 @@ export const glassMaterial = new MeshLambertNodeMaterial({
|
||||
opacity: 0.35,
|
||||
side: THREE.FrontSide,
|
||||
})
|
||||
glassMaterial.userData.__pascalCachedMaterial = true
|
||||
|
||||
function resolveNodeMaterialSide(side: THREE.Side): THREE.Side {
|
||||
return side === THREE.DoubleSide ? THREE.FrontSide : side
|
||||
@@ -445,6 +446,7 @@ export function createMaterialFromPreset(
|
||||
const material =
|
||||
shading === 'solid' ? new MeshLambertNodeMaterial() : new MeshStandardNodeMaterial()
|
||||
applyMaterialPresetToMaterials(material, preset)
|
||||
material.userData.__pascalCachedMaterial = true
|
||||
materialCache.set(cacheKey, material)
|
||||
return material
|
||||
}
|
||||
@@ -494,6 +496,7 @@ export function createMaterial(
|
||||
metalness: props.metalness,
|
||||
})
|
||||
|
||||
threeMaterial.userData.__pascalCachedMaterial = true
|
||||
materialCache.set(cacheKey, threeMaterial)
|
||||
return threeMaterial
|
||||
}
|
||||
@@ -570,6 +573,7 @@ function cachedDefaultMaterial(
|
||||
if (cached) return cached
|
||||
|
||||
const material = createDefaultMaterial(color, roughness, shading, side)
|
||||
material.userData.__pascalCachedMaterial = true
|
||||
defaultMaterialCache.set(cacheKey, material)
|
||||
return material
|
||||
}
|
||||
|
||||
+4
@@ -21,6 +21,8 @@ type ViewerState = {
|
||||
setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void
|
||||
cameraMode: 'perspective' | 'orthographic'
|
||||
setCameraMode: (mode: 'perspective' | 'orthographic') => void
|
||||
isExporting: boolean
|
||||
setExporting: (value: boolean) => void
|
||||
levelMode: 'stacked' | 'exploded' | 'solo' | 'manual'
|
||||
setLevelMode: (mode: 'stacked' | 'exploded' | 'solo' | 'manual') => void
|
||||
wallMode: 'up' | 'cutaway' | 'down'
|
||||
@@ -32,6 +34,8 @@ type ViewerState = {
|
||||
setSelection: (updates: Partial<SelectionPath>) => void
|
||||
resetSelection: () => void
|
||||
outliner: Outliner
|
||||
geometryRevision: number
|
||||
bumpGeometryRevision: () => void
|
||||
exportScene: ((format?: 'glb' | 'stl' | 'obj') => Promise<void>) | null
|
||||
setExportScene: (fn: ((format?: 'glb' | 'stl' | 'obj') => Promise<void>) | null) => void
|
||||
}
|
||||
|
||||
@@ -122,6 +122,10 @@ type ViewerState = {
|
||||
resetSelection: () => void
|
||||
|
||||
outliner: Outliner // No setter as we will manipulate directly the arrays
|
||||
/** Bumped by GeometrySystem after each rebuild pass so selection/outline
|
||||
* effects can re-apply to the freshly swapped meshes. */
|
||||
geometryRevision: number
|
||||
bumpGeometryRevision: () => void
|
||||
|
||||
// Export functionality
|
||||
exportScene: ((format?: 'glb' | 'stl' | 'obj') => Promise<void>) | null
|
||||
@@ -395,6 +399,9 @@ const useViewer = create<ViewerState>()(
|
||||
}),
|
||||
|
||||
outliner: { selectedObjects: [], hoveredObjects: [] },
|
||||
geometryRevision: 0,
|
||||
bumpGeometryRevision: () =>
|
||||
set((state) => ({ geometryRevision: state.geometryRevision + 1 })),
|
||||
|
||||
exportScene: null,
|
||||
setExportScene: (fn) => set({ exportScene: fn }),
|
||||
|
||||
@@ -1,8 +1,30 @@
|
||||
// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not
|
||||
// depend on @types/bun so the import type is unresolved at compile time.
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { Group } from 'three'
|
||||
import { type GeometryBuildCacheEntry, shouldReuseGeometryBuild } from './geometry-system'
|
||||
import { BoxGeometry, Group, Mesh, MeshBasicMaterial } from 'three'
|
||||
import {
|
||||
type GeometryBuildCacheEntry,
|
||||
markGeometryBuildOutput,
|
||||
shouldReuseGeometryBuild,
|
||||
} from './geometry-system'
|
||||
|
||||
describe('markGeometryBuildOutput', () => {
|
||||
test('marks nested builder meshes for slot paint preview', () => {
|
||||
const root = new Group()
|
||||
const nested = new Group()
|
||||
const mesh = new Mesh(new BoxGeometry(1, 1, 1), new MeshBasicMaterial())
|
||||
mesh.userData.slotId = 'appliance'
|
||||
nested.add(mesh)
|
||||
root.add(nested)
|
||||
|
||||
markGeometryBuildOutput(root)
|
||||
|
||||
expect(root.userData.__fromGeometry).toBe(true)
|
||||
expect(nested.userData.__fromGeometry).toBe(true)
|
||||
expect(mesh.userData.__fromGeometry).toBe(true)
|
||||
expect(mesh.userData.slotId).toBe('appliance')
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldReuseGeometryBuild', () => {
|
||||
test('rebuilds when the same node id remounts into a new group with the same key', () => {
|
||||
|
||||
@@ -8,11 +8,12 @@ import {
|
||||
nodeRegistry,
|
||||
type SurfaceRole,
|
||||
sceneRegistry,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { FrontSide, type Group, type Material, type Mesh } from 'three'
|
||||
import { FrontSide, type Group, type Material, type Mesh, type Object3D } from 'three'
|
||||
import {
|
||||
type ColorPreset,
|
||||
createSurfaceRoleMaterial,
|
||||
@@ -58,6 +59,7 @@ export const GeometrySystem = () => {
|
||||
const textures = useViewer((s) => s.textures)
|
||||
const colorPreset = useViewer((s) => s.colorPreset)
|
||||
const sceneTheme = useViewer((s) => s.sceneTheme)
|
||||
const bumpGeometryRevision = useViewer((s) => s.bumpGeometryRevision)
|
||||
// The shared scene-material library, threaded into each builder's ctx so
|
||||
// pure geometry builders can resolve `scene:<id>` slot refs without
|
||||
// importing `useScene`.
|
||||
@@ -107,6 +109,7 @@ export const GeometrySystem = () => {
|
||||
useFrame(() => {
|
||||
if (dirtyNodes.size === 0) return
|
||||
const nodes = useScene.getState().nodes
|
||||
let rebuiltGeometry = false
|
||||
|
||||
// Phase 1 — group dirty nodes by (kind, parentId). Kinds that
|
||||
// declare `def.computeLevelData` get one batch precompute per
|
||||
@@ -187,7 +190,8 @@ export const GeometrySystem = () => {
|
||||
// never skipped. This kills the board remount + pointer enter/leave
|
||||
// churn when an item reparents onto a shelf.
|
||||
if (def.geometryKey) {
|
||||
const builtKey = `${shading}|${textures}|${colorPreset}|${sceneTheme}|${def.geometryKey(effectiveNode)}`
|
||||
const childLiveOverrideKey = liveChildOverrideKey(node)
|
||||
const builtKey = `${shading}|${textures}|${colorPreset}|${sceneTheme}|${def.geometryKey(effectiveNode)}|${childLiveOverrideKey}`
|
||||
if (shouldReuseGeometryBuild(builtGeometryKeyRef.current, id, group, builtKey)) {
|
||||
clearDirty(id as AnyNodeId)
|
||||
continue
|
||||
@@ -219,21 +223,10 @@ export const GeometrySystem = () => {
|
||||
|
||||
disposeChildren(group)
|
||||
for (const child of [...built.children]) {
|
||||
// Tag every child the builder produced so a subsequent rebuild
|
||||
// can dispose only THIS rebuild's outputs and leave React-
|
||||
// mounted siblings (hosted items inside a shelf / slab / etc.)
|
||||
// alone. Without this, a parent rebuild triggered by a child
|
||||
// event (e.g. an item reparenting onto a shelf calls
|
||||
// `dirtyNodes.add(parent)` in `ItemRenderer`'s effect) would
|
||||
// wipe ALL of the parent group's children — including the
|
||||
// freshly-mounted item — leaving the item in scene state but
|
||||
// invisible.
|
||||
;(child as { userData?: Record<string, unknown> }).userData = {
|
||||
...(child as { userData?: Record<string, unknown> }).userData,
|
||||
__fromGeometry: true,
|
||||
}
|
||||
markGeometryBuildOutput(child)
|
||||
group.add(child)
|
||||
}
|
||||
rebuiltGeometry = true
|
||||
// NOTE: we intentionally do NOT reset `group.position` / `group.rotation`
|
||||
// here. The `ParametricNodeRenderer` binds them via JSX (`position={...}`
|
||||
// / `rotation={...}`) driven by `useLiveTransforms` during drag and
|
||||
@@ -245,11 +238,25 @@ export const GeometrySystem = () => {
|
||||
|
||||
clearDirty(id as AnyNodeId)
|
||||
}
|
||||
if (rebuiltGeometry) bumpGeometryRevision()
|
||||
}, 2)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function liveChildOverrideKey(node: AnyNode): string {
|
||||
const childIds = (node as unknown as { children?: AnyNodeId[] }).children
|
||||
if (!Array.isArray(childIds) || childIds.length === 0) return ''
|
||||
|
||||
const overrides = useLiveNodeOverrides.getState().overrides
|
||||
const entries: Array<[AnyNodeId, unknown]> = []
|
||||
for (const childId of childIds) {
|
||||
const override = overrides.get(childId)
|
||||
if (override) entries.push([childId, override])
|
||||
}
|
||||
return entries.length === 0 ? '' : JSON.stringify(entries)
|
||||
}
|
||||
|
||||
function nodeReferencesSceneMaterial(node: AnyNode): boolean {
|
||||
const slots = (node as { slots?: Record<string, string> }).slots
|
||||
if (!slots) return false
|
||||
@@ -265,15 +272,18 @@ function buildGeometryContext(
|
||||
levelData: unknown,
|
||||
materials: GeometryContext['materials'],
|
||||
): GeometryContext {
|
||||
const resolve = <N = AnyNode>(id: AnyNodeId): N | undefined => nodes[id] as N | undefined
|
||||
const resolve = <N = AnyNode>(id: AnyNodeId): N | undefined => {
|
||||
const resolved = nodes[id]
|
||||
return resolved ? (getEffectiveNode(resolved) as N) : undefined
|
||||
}
|
||||
|
||||
const childIds = (node as unknown as { children?: AnyNodeId[] }).children
|
||||
const children: AnyNode[] = Array.isArray(childIds)
|
||||
? childIds.map((cid) => nodes[cid]).filter((n): n is AnyNode => n !== undefined)
|
||||
? childIds.map((cid) => resolve<AnyNode>(cid)).filter((n): n is AnyNode => n !== undefined)
|
||||
: []
|
||||
|
||||
const parentId = node.parentId as AnyNodeId | null
|
||||
const parent: AnyNode | null = parentId ? (nodes[parentId] ?? null) : null
|
||||
const parent: AnyNode | null = parentId ? (resolve<AnyNode>(parentId) ?? null) : null
|
||||
|
||||
// Siblings = same kind, same parent, excluding self. Walks the parent's
|
||||
// children array; falls back to scanning the whole scene if the parent
|
||||
@@ -284,13 +294,13 @@ function buildGeometryContext(
|
||||
if (Array.isArray(parentChildIds)) {
|
||||
for (const sid of parentChildIds) {
|
||||
if (sid === node.id) continue
|
||||
const s = nodes[sid]
|
||||
const s = resolve<AnyNode>(sid)
|
||||
if (s && s.type === node.type) siblings.push(s)
|
||||
}
|
||||
} else {
|
||||
siblings = Object.values(nodes).filter(
|
||||
(n) => n !== node && n.type === node.type && n.parentId === parentId,
|
||||
)
|
||||
siblings = Object.values(nodes)
|
||||
.filter((n) => n !== node && n.type === node.type && n.parentId === parentId)
|
||||
.map((n) => getEffectiveNode(n))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,6 +396,18 @@ export type GeometryBuildCacheEntry = {
|
||||
key: string
|
||||
}
|
||||
|
||||
export function markGeometryBuildOutput(child: Object3D): void {
|
||||
// Tag the builder subtree so paint previews can reach nested meshes (for
|
||||
// example a cabinet sink faucet handle), while disposal still removes only
|
||||
// top-level builder children from the registered group.
|
||||
child.traverse((object) => {
|
||||
object.userData = {
|
||||
...object.userData,
|
||||
__fromGeometry: true,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function shouldReuseGeometryBuild(
|
||||
cache: Map<string, GeometryBuildCacheEntry>,
|
||||
id: string,
|
||||
|
||||
@@ -24,6 +24,7 @@ import * as THREE from 'three'
|
||||
import { mergeGeometries, mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
||||
import { ADDITION, Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg'
|
||||
import { computeBoundsTree } from 'three-mesh-bvh'
|
||||
import { applyWorldScaleBoxUVs } from '../../lib/box-uv'
|
||||
import { ensureRenderableGeometryAttributes } from '../../lib/csg-utils'
|
||||
|
||||
function csgGeometry(brush: Brush): THREE.BufferGeometry {
|
||||
@@ -905,6 +906,23 @@ function hasSegmentTrim(node: RoofSegmentNode): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
// Material slot the freshly exposed trim plane is assigned to. Slot 0 is the
|
||||
// wall/trim band — the same finish the gable walls render with (concrete-drywall
|
||||
// by default, continuous with the walls below). `remapRoofShellFaces` does not
|
||||
// reclassify slot 0, so any new interior face CSG carves out of a BoxGeometry
|
||||
// cutter lands on the wall band regardless of which box face it came from.
|
||||
// Without this the box's default 6 groups (materialIndex 0..5) collapse via
|
||||
// mod-4 and `remapRoofShellFaces` would reshuffle the vertical ones across
|
||||
// slots. Accessories still clamp the slot via `useSegmentTrimClippedGeometry`
|
||||
// when they expose fewer material slots.
|
||||
const TRIM_CUT_MATERIAL_SLOT = 0
|
||||
|
||||
function assignTrimCutterSlot(geometry: THREE.BufferGeometry): void {
|
||||
geometry.clearGroups()
|
||||
const count = geometry.index ? geometry.index.count : geometry.getAttribute('position').count
|
||||
geometry.addGroup(0, count, TRIM_CUT_MATERIAL_SLOT)
|
||||
}
|
||||
|
||||
function buildTrimCutBrush(
|
||||
minX: number,
|
||||
maxX: number,
|
||||
@@ -920,6 +938,11 @@ function buildTrimCutBrush(
|
||||
|
||||
const geometry = new THREE.BoxGeometry(width, height, depth)
|
||||
geometry.translate((minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2)
|
||||
// World-metre UVs on the cutter so the newly exposed interior faces produced
|
||||
// by CSG inherit tiled UVs (1 uv unit per metre) instead of the box's default
|
||||
// 0→1-per-face, which would stretch the finish to fit each cut face.
|
||||
applyWorldScaleBoxUVs(geometry, width, height, depth)
|
||||
assignTrimCutterSlot(geometry)
|
||||
ensureRenderableGeometryAttributes(geometry)
|
||||
computeGeometryBoundsTree(geometry)
|
||||
|
||||
@@ -974,6 +997,9 @@ function buildDiagonalTrimCutBrush(
|
||||
const geometry = new THREE.BoxGeometry(cutterLength, height, cutterDepth)
|
||||
geometry.rotateY(yaw)
|
||||
geometry.translate(centerX, (bounds.minY + bounds.maxY) / 2, centerZ)
|
||||
// World-metre UVs so the diagonal cut's interior face inherits tiled UVs.
|
||||
applyWorldScaleBoxUVs(geometry, cutterLength, height, cutterDepth)
|
||||
assignTrimCutterSlot(geometry)
|
||||
ensureRenderableGeometryAttributes(geometry)
|
||||
computeGeometryBoundsTree(geometry)
|
||||
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import { type AnyNodeId, emitter, sceneRegistry, useScene, type WallNode } from '@pascal-app/core'
|
||||
import {
|
||||
type AnyNodeId,
|
||||
emitter,
|
||||
getWallFaceBandConfig,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { Material } from 'three'
|
||||
import { type Mesh, Vector3 } from 'three/webgpu'
|
||||
import useViewer from '../../store/use-viewer'
|
||||
import { getMaterialsForWall, getSelectionHighlightMaterials } from './wall-materials'
|
||||
import {
|
||||
getMaterialsForWall,
|
||||
getSelectionHighlightMaterials,
|
||||
getWallMaterialHash,
|
||||
} from './wall-materials'
|
||||
|
||||
const tmpVec = new Vector3()
|
||||
const u = new Vector3()
|
||||
@@ -48,6 +59,7 @@ export const WallCutout = () => {
|
||||
const lastShading = useRef(useViewer.getState().shading)
|
||||
const lastNumberOfWalls = useRef(0)
|
||||
const lastHighlightKey = useRef('')
|
||||
const lastWallAppearanceKey = useRef('')
|
||||
const lastTextures = useRef(useViewer.getState().textures)
|
||||
const lastColorPreset = useRef(useViewer.getState().colorPreset)
|
||||
const lastSceneTheme = useRef(useViewer.getState().sceneTheme)
|
||||
@@ -62,22 +74,31 @@ export const WallCutout = () => {
|
||||
const previewSelectedIds = useViewer.getState().previewSelectedIds
|
||||
const hoveredId = useViewer.getState().hoveredId
|
||||
const hoverHighlightMode = useViewer.getState().hoverHighlightMode
|
||||
const sceneState = useScene.getState()
|
||||
const currentTime = clock.elapsedTime
|
||||
const currentCameraPosition = camera.position
|
||||
camera.getWorldDirection(tmpVec)
|
||||
tmpVec.add(currentCameraPosition)
|
||||
const highlightedWallIds = new Set(
|
||||
[...selectedIds, ...previewSelectedIds].filter(
|
||||
(id) => useScene.getState().nodes[id as AnyNodeId]?.type === 'wall',
|
||||
(id) => sceneState.nodes[id as AnyNodeId]?.type === 'wall',
|
||||
),
|
||||
)
|
||||
const deleteHoveredWallId =
|
||||
hoverHighlightMode === 'delete' &&
|
||||
hoveredId &&
|
||||
useScene.getState().nodes[hoveredId as AnyNodeId]?.type === 'wall'
|
||||
sceneState.nodes[hoveredId as AnyNodeId]?.type === 'wall'
|
||||
? hoveredId
|
||||
: null
|
||||
const highlightKey = `${Array.from(highlightedWallIds).sort().join('|')}::${deleteHoveredWallId ?? ''}`
|
||||
const wallAppearanceKey = Array.from(sceneRegistry.byType.wall!)
|
||||
.sort()
|
||||
.map((wallId) => {
|
||||
const wallNode = sceneState.nodes[wallId as WallNode['id']]
|
||||
if (wallNode?.type !== 'wall') return `${wallId}:missing`
|
||||
return `${wallId}:${getWallMaterialHash(wallNode, shading, sceneState.materials)}:${JSON.stringify(wallNode.faceBands ?? null)}`
|
||||
})
|
||||
.join('|')
|
||||
|
||||
const distanceMoved = currentCameraPosition.distanceTo(lastCameraPosition.current)
|
||||
const directionChanged = tmpVec.distanceTo(lastCameraTarget.current)
|
||||
@@ -91,7 +112,8 @@ export const WallCutout = () => {
|
||||
lastColorPreset.current !== colorPreset ||
|
||||
lastSceneTheme.current !== sceneTheme ||
|
||||
sceneRegistry.byType.wall!.size !== lastNumberOfWalls.current ||
|
||||
lastHighlightKey.current !== highlightKey
|
||||
lastHighlightKey.current !== highlightKey ||
|
||||
lastWallAppearanceKey.current !== wallAppearanceKey
|
||||
) {
|
||||
lastCameraPosition.current.copy(currentCameraPosition)
|
||||
lastCameraTarget.current.copy(tmpVec)
|
||||
@@ -102,37 +124,39 @@ export const WallCutout = () => {
|
||||
walls.forEach((wallId) => {
|
||||
const wallMesh = sceneRegistry.nodes.get(wallId)
|
||||
if (!wallMesh) return
|
||||
const wallNode = useScene.getState().nodes[wallId as WallNode['id']]
|
||||
const wallNode = sceneState.nodes[wallId as WallNode['id']]
|
||||
if (wallNode?.type !== 'wall') return
|
||||
|
||||
const hideWall = getWallHideState(wallNode, wallMesh as Mesh, wallMode, u)
|
||||
const isDeleteHighlighted = deleteHoveredWallId === wallId
|
||||
const isSelectionHighlighted = !isDeleteHighlighted && highlightedWallIds.has(wallId)
|
||||
const shouldSelectionHighlight =
|
||||
isSelectionHighlighted && !getWallFaceBandConfig(wallNode).enabled
|
||||
const materials = getMaterialsForWall(
|
||||
wallNode,
|
||||
shading,
|
||||
textures,
|
||||
colorPreset,
|
||||
sceneTheme,
|
||||
useScene.getState().materials,
|
||||
sceneState.materials,
|
||||
)
|
||||
|
||||
if (wallMode === 'translucent') {
|
||||
;(wallMesh as Mesh).material = isDeleteHighlighted
|
||||
? materials.deleteTranslucent
|
||||
: isSelectionHighlighted
|
||||
: shouldSelectionHighlight
|
||||
? getSelectionHighlightMaterials(materials.translucent)
|
||||
: materials.translucent
|
||||
} else if (hideWall) {
|
||||
;(wallMesh as Mesh).material = isDeleteHighlighted
|
||||
? materials.deleteInvisible
|
||||
: isSelectionHighlighted
|
||||
: shouldSelectionHighlight
|
||||
? getSelectionHighlightMaterials(materials.invisible)
|
||||
: materials.invisible
|
||||
} else {
|
||||
;(wallMesh as Mesh).material = isDeleteHighlighted
|
||||
? materials.deleteVisible
|
||||
: isSelectionHighlighted
|
||||
: shouldSelectionHighlight
|
||||
? getSelectionHighlightMaterials(materials.visible)
|
||||
: materials.visible
|
||||
}
|
||||
@@ -144,6 +168,7 @@ export const WallCutout = () => {
|
||||
lastSceneTheme.current = sceneTheme
|
||||
lastNumberOfWalls.current = sceneRegistry.byType.wall!.size
|
||||
lastHighlightKey.current = highlightKey
|
||||
lastWallAppearanceKey.current = wallAppearanceKey
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -2,14 +2,17 @@ import {
|
||||
getEffectiveWallSurfaceMaterial,
|
||||
getMaterialPresetByRef,
|
||||
getWallSurfaceMaterialSignature,
|
||||
getWallSurfaceSideFromBandSlot,
|
||||
parseMaterialRef,
|
||||
resolveMaterial,
|
||||
type SceneMaterial,
|
||||
type SceneMaterialId,
|
||||
WALL_SLOT_DEFAULT,
|
||||
WALL_SURFACE_SLOT_DEFAULTS,
|
||||
type WallNode,
|
||||
type WallSurfaceMaterialSpec,
|
||||
type WallSurfaceSide,
|
||||
type WallSurfaceSlotId,
|
||||
} from '@pascal-app/core'
|
||||
import { Color, type Material } from 'three'
|
||||
import { Fn, float, fract, length, mix, positionLocal, smoothstep, step, vec2 } from 'three/tsl'
|
||||
@@ -41,7 +44,7 @@ const WALL_HIGHLIGHT_PROFILES = {
|
||||
|
||||
type WallHighlightKind = keyof typeof WALL_HIGHLIGHT_PROFILES
|
||||
|
||||
export type WallMaterialArray = [Material, Material, Material]
|
||||
export type WallMaterialArray = Material[]
|
||||
|
||||
export interface WallMaterials {
|
||||
visible: WallMaterialArray
|
||||
@@ -126,6 +129,28 @@ function resolveWallFaceMaterial(
|
||||
return resolveWallSlotDefault(WALL_SLOT_DEFAULT[side], shading)
|
||||
}
|
||||
|
||||
function resolveWallSlotMaterial(
|
||||
wallNode: WallNode,
|
||||
slotId: WallSurfaceSlotId,
|
||||
shading: RenderShading,
|
||||
sceneMaterials: SceneMaterials,
|
||||
): Material {
|
||||
const ref = wallNode.slots?.[slotId]
|
||||
if (ref) {
|
||||
return (
|
||||
resolveMaterialRef(ref, sceneMaterials, shading) ??
|
||||
resolveWallSlotDefault(WALL_SURFACE_SLOT_DEFAULTS[slotId], shading)
|
||||
)
|
||||
}
|
||||
|
||||
const side = getWallSurfaceSideFromBandSlot(slotId)
|
||||
if (side) {
|
||||
return resolveWallFaceMaterial(wallNode, side, shading, sceneMaterials)
|
||||
}
|
||||
|
||||
return resolveWallSlotDefault(WALL_SURFACE_SLOT_DEFAULTS[slotId], shading)
|
||||
}
|
||||
|
||||
// Cache-key fragment for one face: the slot ref plus, for a `scene:` ref, the
|
||||
// referenced material's *content* — so editing a scene material assigned to a
|
||||
// wall invalidates the cache (a `library:` ref is static catalog content, so
|
||||
@@ -149,6 +174,28 @@ function wallFaceMaterialSignature(
|
||||
return getWallSurfaceMaterialSignature(getEffectiveWallSurfaceMaterial(wallNode, side))
|
||||
}
|
||||
|
||||
function wallSlotMaterialSignature(
|
||||
wallNode: WallNode,
|
||||
slotId: WallSurfaceSlotId,
|
||||
sceneMaterials: SceneMaterials,
|
||||
): string {
|
||||
const ref = wallNode.slots?.[slotId]
|
||||
if (ref) {
|
||||
const parsed = parseMaterialRef(ref)
|
||||
if (parsed?.kind === 'scene') {
|
||||
return JSON.stringify({
|
||||
ref,
|
||||
material: sceneMaterials?.[parsed.id as SceneMaterialId]?.material ?? null,
|
||||
})
|
||||
}
|
||||
return JSON.stringify({ ref })
|
||||
}
|
||||
|
||||
const side = getWallSurfaceSideFromBandSlot(slotId)
|
||||
if (side) return wallFaceMaterialSignature(wallNode, side, sceneMaterials)
|
||||
return JSON.stringify({ default: WALL_SURFACE_SLOT_DEFAULTS[slotId] })
|
||||
}
|
||||
|
||||
// Slot-first tint for the cutaway/invisible wall variant.
|
||||
function resolveWallFaceColor(
|
||||
wallNode: WallNode,
|
||||
@@ -171,6 +218,28 @@ function resolveWallFaceColor(
|
||||
return getSurfaceColor(getEffectiveWallSurfaceMaterial(wallNode, side), fallback)
|
||||
}
|
||||
|
||||
function resolveWallSlotColor(
|
||||
wallNode: WallNode,
|
||||
slotId: WallSurfaceSlotId,
|
||||
sceneMaterials: SceneMaterials,
|
||||
fallback: string,
|
||||
): string {
|
||||
const ref = wallNode.slots?.[slotId]
|
||||
if (ref) {
|
||||
const parsed = parseMaterialRef(ref)
|
||||
if (parsed?.kind === 'library') {
|
||||
return getMaterialPresetByRef(ref)?.mapProperties?.color ?? fallback
|
||||
}
|
||||
if (parsed?.kind === 'scene') {
|
||||
const sceneMaterial = sceneMaterials?.[parsed.id as SceneMaterialId]
|
||||
return sceneMaterial ? resolveMaterial(sceneMaterial.material).color : fallback
|
||||
}
|
||||
}
|
||||
|
||||
const side = getWallSurfaceSideFromBandSlot(slotId)
|
||||
return side ? resolveWallFaceColor(wallNode, side, sceneMaterials, fallback) : fallback
|
||||
}
|
||||
|
||||
function getSurfaceColor(spec: WallSurfaceMaterialSpec, fallback = DEFAULT_WALL_COLOR): string {
|
||||
const preset = getMaterialPresetByRef(spec.materialPreset)
|
||||
if (preset?.mapProperties?.color) {
|
||||
@@ -346,6 +415,14 @@ export function getWallMaterialHash(
|
||||
shading,
|
||||
interior: wallFaceMaterialSignature(wallNode, 'interior', sceneMaterials),
|
||||
exterior: wallFaceMaterialSignature(wallNode, 'exterior', sceneMaterials),
|
||||
lowerInterior: wallSlotMaterialSignature(wallNode, 'lowerInterior', sceneMaterials),
|
||||
middleInterior: wallSlotMaterialSignature(wallNode, 'middleInterior', sceneMaterials),
|
||||
upperInterior: wallSlotMaterialSignature(wallNode, 'upperInterior', sceneMaterials),
|
||||
topInterior: wallSlotMaterialSignature(wallNode, 'topInterior', sceneMaterials),
|
||||
lowerExterior: wallSlotMaterialSignature(wallNode, 'lowerExterior', sceneMaterials),
|
||||
middleExterior: wallSlotMaterialSignature(wallNode, 'middleExterior', sceneMaterials),
|
||||
upperExterior: wallSlotMaterialSignature(wallNode, 'upperExterior', sceneMaterials),
|
||||
topExterior: wallSlotMaterialSignature(wallNode, 'topExterior', sceneMaterials),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -388,39 +465,77 @@ export function getMaterialsForWall(
|
||||
wallRoleMaterial,
|
||||
resolveWallFaceMaterial(wallNode, 'interior', shading, sceneMaterials),
|
||||
resolveWallFaceMaterial(wallNode, 'exterior', shading, sceneMaterials),
|
||||
resolveWallSlotMaterial(wallNode, 'lowerInterior', shading, sceneMaterials),
|
||||
resolveWallSlotMaterial(wallNode, 'middleInterior', shading, sceneMaterials),
|
||||
resolveWallSlotMaterial(wallNode, 'upperInterior', shading, sceneMaterials),
|
||||
resolveWallSlotMaterial(wallNode, 'topInterior', shading, sceneMaterials),
|
||||
resolveWallSlotMaterial(wallNode, 'lowerExterior', shading, sceneMaterials),
|
||||
resolveWallSlotMaterial(wallNode, 'middleExterior', shading, sceneMaterials),
|
||||
resolveWallSlotMaterial(wallNode, 'upperExterior', shading, sceneMaterials),
|
||||
resolveWallSlotMaterial(wallNode, 'topExterior', shading, sceneMaterials),
|
||||
]
|
||||
: [wallRoleMaterial, wallRoleMaterial, wallRoleMaterial]
|
||||
: Array.from({ length: 11 }, () => wallRoleMaterial)
|
||||
|
||||
const wallRoleColor = resolveSurfaceColor('wall', colorPreset, sceneTheme)
|
||||
const invisible: WallMaterialArray = [
|
||||
createInvisibleWallMaterial(wallRoleColor, textures ? shading : 'solid'),
|
||||
createInvisibleWallMaterial(
|
||||
textures
|
||||
? resolveWallFaceColor(wallNode, 'interior', sceneMaterials, wallRoleColor)
|
||||
: wallRoleColor,
|
||||
textures ? shading : 'solid',
|
||||
...(['interior', 'exterior'] as WallSurfaceSide[]).map((side) =>
|
||||
createInvisibleWallMaterial(
|
||||
textures
|
||||
? resolveWallFaceColor(wallNode, side, sceneMaterials, wallRoleColor)
|
||||
: wallRoleColor,
|
||||
textures ? shading : 'solid',
|
||||
),
|
||||
),
|
||||
createInvisibleWallMaterial(
|
||||
textures
|
||||
? resolveWallFaceColor(wallNode, 'exterior', sceneMaterials, wallRoleColor)
|
||||
: wallRoleColor,
|
||||
textures ? shading : 'solid',
|
||||
...(
|
||||
[
|
||||
'lowerInterior',
|
||||
'middleInterior',
|
||||
'upperInterior',
|
||||
'topInterior',
|
||||
'lowerExterior',
|
||||
'middleExterior',
|
||||
'upperExterior',
|
||||
'topExterior',
|
||||
] as WallSurfaceSlotId[]
|
||||
).map((slotId) =>
|
||||
createInvisibleWallMaterial(
|
||||
textures
|
||||
? resolveWallSlotColor(wallNode, slotId, sceneMaterials, wallRoleColor)
|
||||
: wallRoleColor,
|
||||
textures ? shading : 'solid',
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
const translucent: WallMaterialArray = [
|
||||
createTranslucentWallMaterial(wallRoleColor, textures ? shading : 'solid'),
|
||||
createTranslucentWallMaterial(
|
||||
textures
|
||||
? resolveWallFaceColor(wallNode, 'interior', sceneMaterials, wallRoleColor)
|
||||
: wallRoleColor,
|
||||
textures ? shading : 'solid',
|
||||
...(['interior', 'exterior'] as WallSurfaceSide[]).map((side) =>
|
||||
createTranslucentWallMaterial(
|
||||
textures
|
||||
? resolveWallFaceColor(wallNode, side, sceneMaterials, wallRoleColor)
|
||||
: wallRoleColor,
|
||||
textures ? shading : 'solid',
|
||||
),
|
||||
),
|
||||
createTranslucentWallMaterial(
|
||||
textures
|
||||
? resolveWallFaceColor(wallNode, 'exterior', sceneMaterials, wallRoleColor)
|
||||
: wallRoleColor,
|
||||
textures ? shading : 'solid',
|
||||
...(
|
||||
[
|
||||
'lowerInterior',
|
||||
'middleInterior',
|
||||
'upperInterior',
|
||||
'topInterior',
|
||||
'lowerExterior',
|
||||
'middleExterior',
|
||||
'upperExterior',
|
||||
'topExterior',
|
||||
] as WallSurfaceSlotId[]
|
||||
).map((slotId) =>
|
||||
createTranslucentWallMaterial(
|
||||
textures
|
||||
? resolveWallSlotColor(wallNode, slotId, sceneMaterials, wallRoleColor)
|
||||
: wallRoleColor,
|
||||
textures ? shading : 'solid',
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
type DoorNode,
|
||||
getAdjacentWallIds,
|
||||
getEffectiveNode,
|
||||
getWallBandSlotId,
|
||||
getWallCurveFrameAt,
|
||||
getWallFaceBandConfig,
|
||||
getWallFaceBandForHeight,
|
||||
getWallMiterBoundaryPoints,
|
||||
getWallPlanFootprint,
|
||||
getWallSurfacePolygon,
|
||||
@@ -22,6 +25,8 @@ import {
|
||||
useScene,
|
||||
type WallMiterData,
|
||||
type WallNode,
|
||||
type WallSurfaceSide,
|
||||
type WallSurfaceSlotId,
|
||||
type WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
@@ -37,6 +42,25 @@ csgEvaluator.attributes = ['position', 'normal', 'uv', 'uv2']
|
||||
const CURVED_WALL_3D_ENDPOINT_INSET = 0.0015
|
||||
const WALL_FACE_NORMAL_Y_EPSILON = 0.6
|
||||
const WALL_FACE_EDGE_DISTANCE_EPSILON = 0.003
|
||||
const WALL_BAND_SPLIT_EPSILON = 1e-5
|
||||
const WALL_BAND_SLOT_MATERIAL_INDEX: Record<WallSurfaceSlotId, number> = {
|
||||
interior: 1,
|
||||
exterior: 2,
|
||||
lowerInterior: 3,
|
||||
middleInterior: 4,
|
||||
upperInterior: 5,
|
||||
topInterior: 6,
|
||||
lowerExterior: 7,
|
||||
middleExterior: 8,
|
||||
upperExterior: 9,
|
||||
topExterior: 10,
|
||||
skirtingInterior: 0,
|
||||
skirtingExterior: 0,
|
||||
crownInterior: 0,
|
||||
crownExterior: 0,
|
||||
chairRailInterior: 0,
|
||||
chairRailExterior: 0,
|
||||
}
|
||||
|
||||
function computeGeometryBoundsTree(geometry: THREE.BufferGeometry) {
|
||||
;(geometry as any).computeBoundsTree = computeBoundsTree
|
||||
@@ -191,15 +215,19 @@ function distanceToWallBoundaryEdge(point: THREE.Vector2, edge: TaggedWallBounda
|
||||
}
|
||||
|
||||
function getWallFaceMaterialIndex(
|
||||
wall: Pick<WallNode, 'frontSide' | 'backSide'>,
|
||||
wall: Pick<WallNode, 'frontSide' | 'backSide' | 'height' | 'faceBands'>,
|
||||
face: 'front' | 'back',
|
||||
): 0 | 1 | 2 {
|
||||
y: number,
|
||||
): number {
|
||||
const semantic = face === 'front' ? wall.frontSide : wall.backSide
|
||||
const fallback = face === 'front' ? 1 : 2
|
||||
const fallback: WallSurfaceSide = face === 'front' ? 'interior' : 'exterior'
|
||||
const side = semantic === 'interior' || semantic === 'exterior' ? semantic : fallback
|
||||
|
||||
if (semantic === 'interior') return 1
|
||||
if (semantic === 'exterior') return 2
|
||||
return fallback
|
||||
const bands = getWallFaceBandConfig(wall)
|
||||
if (!bands.enabled) return WALL_BAND_SLOT_MATERIAL_INDEX[side]
|
||||
|
||||
const band = getWallFaceBandForHeight(wall, y)
|
||||
return WALL_BAND_SLOT_MATERIAL_INDEX[getWallBandSlotId(side, band)]
|
||||
}
|
||||
|
||||
function assignWallMaterialGroups(
|
||||
@@ -285,7 +313,7 @@ function assignWallMaterialGroups(
|
||||
continue
|
||||
}
|
||||
|
||||
triangleMaterials[triangleIndex] = getWallFaceMaterialIndex(wall, nearestTag)
|
||||
triangleMaterials[triangleIndex] = getWallFaceMaterialIndex(wall, nearestTag, centroid.y)
|
||||
}
|
||||
|
||||
geometry.clearGroups()
|
||||
@@ -305,6 +333,126 @@ function assignWallMaterialGroups(
|
||||
geometry.addGroup(groupStart * 3, (triangleCount - groupStart) * 3, currentMaterial)
|
||||
}
|
||||
|
||||
type SplitVertex = {
|
||||
x: number
|
||||
y: number
|
||||
z: number
|
||||
}
|
||||
|
||||
function interpolateSplitVertex(a: SplitVertex, b: SplitVertex, t: number): SplitVertex {
|
||||
return {
|
||||
x: a.x + (b.x - a.x) * t,
|
||||
y: a.y + (b.y - a.y) * t,
|
||||
z: a.z + (b.z - a.z) * t,
|
||||
}
|
||||
}
|
||||
|
||||
function clipPolygonByY(polygon: SplitVertex[], planeY: number, keepBelow: boolean): SplitVertex[] {
|
||||
const out: SplitVertex[] = []
|
||||
if (polygon.length === 0) return out
|
||||
|
||||
const isInside = (vertex: SplitVertex) =>
|
||||
keepBelow
|
||||
? vertex.y <= planeY + WALL_BAND_SPLIT_EPSILON
|
||||
: vertex.y >= planeY - WALL_BAND_SPLIT_EPSILON
|
||||
|
||||
for (let index = 0; index < polygon.length; index += 1) {
|
||||
const current = polygon[index]!
|
||||
const previous = polygon[(index + polygon.length - 1) % polygon.length]!
|
||||
const currentInside = isInside(current)
|
||||
const previousInside = isInside(previous)
|
||||
|
||||
if (currentInside !== previousInside) {
|
||||
const denom = current.y - previous.y
|
||||
if (Math.abs(denom) > WALL_BAND_SPLIT_EPSILON) {
|
||||
out.push(interpolateSplitVertex(previous, current, (planeY - previous.y) / denom))
|
||||
}
|
||||
}
|
||||
if (currentInside) out.push(current)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
function triangulateSplitPolygon(polygon: SplitVertex[], positions: number[]) {
|
||||
if (polygon.length < 3) return
|
||||
const first = polygon[0]!
|
||||
for (let index = 1; index < polygon.length - 1; index += 1) {
|
||||
const b = polygon[index]!
|
||||
const c = polygon[index + 1]!
|
||||
positions.push(first.x, first.y, first.z, b.x, b.y, b.z, c.x, c.y, c.z)
|
||||
}
|
||||
}
|
||||
|
||||
function splitGeometryAtHorizontalPlanes(
|
||||
geometry: THREE.BufferGeometry,
|
||||
planes: number[],
|
||||
): THREE.BufferGeometry {
|
||||
const splitPlanes = Array.from(
|
||||
new Set(
|
||||
planes
|
||||
.filter((plane) => Number.isFinite(plane) && plane > WALL_BAND_SPLIT_EPSILON)
|
||||
.map((plane) => Math.round(plane / WALL_BAND_SPLIT_EPSILON) * WALL_BAND_SPLIT_EPSILON),
|
||||
),
|
||||
).sort((a, b) => a - b)
|
||||
if (splitPlanes.length === 0) return geometry
|
||||
|
||||
const source = geometry.index ? geometry.toNonIndexed() : geometry
|
||||
const position = source.getAttribute('position')
|
||||
if (!position || position.count === 0) return source
|
||||
|
||||
const positions: number[] = []
|
||||
for (let index = 0; index < position.count; index += 3) {
|
||||
let polygons: SplitVertex[][] = [
|
||||
[
|
||||
{ x: position.getX(index), y: position.getY(index), z: position.getZ(index) },
|
||||
{ x: position.getX(index + 1), y: position.getY(index + 1), z: position.getZ(index + 1) },
|
||||
{ x: position.getX(index + 2), y: position.getY(index + 2), z: position.getZ(index + 2) },
|
||||
],
|
||||
]
|
||||
|
||||
for (const plane of splitPlanes) {
|
||||
const next: SplitVertex[][] = []
|
||||
for (const polygon of polygons) {
|
||||
const minY = Math.min(...polygon.map((vertex) => vertex.y))
|
||||
const maxY = Math.max(...polygon.map((vertex) => vertex.y))
|
||||
if (plane <= minY + WALL_BAND_SPLIT_EPSILON || plane >= maxY - WALL_BAND_SPLIT_EPSILON) {
|
||||
next.push(polygon)
|
||||
continue
|
||||
}
|
||||
|
||||
const below = clipPolygonByY(polygon, plane, true)
|
||||
const above = clipPolygonByY(polygon, plane, false)
|
||||
if (below.length >= 3) next.push(below)
|
||||
if (above.length >= 3) next.push(above)
|
||||
}
|
||||
polygons = next
|
||||
}
|
||||
|
||||
for (const polygon of polygons) triangulateSplitPolygon(polygon, positions)
|
||||
}
|
||||
|
||||
if (source !== geometry) geometry.dispose()
|
||||
source.dispose()
|
||||
|
||||
const split = new THREE.BufferGeometry()
|
||||
split.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
|
||||
split.computeVertexNormals()
|
||||
return split
|
||||
}
|
||||
|
||||
function getWallBandSplitPlanes(wall: WallNode): number[] {
|
||||
const bands = getWallFaceBandConfig(wall)
|
||||
if (!bands.enabled) return []
|
||||
const planes = [bands.lowerTop]
|
||||
if (bands.count >= 3) planes.push(bands.middleTop)
|
||||
if (bands.count >= 4) planes.push(bands.upperTop)
|
||||
return planes.filter(
|
||||
(plane) =>
|
||||
plane > WALL_BAND_SPLIT_EPSILON && plane < (wall.height ?? 2.5) - WALL_BAND_SPLIT_EPSILON,
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WALL SYSTEM
|
||||
// ============================================================================
|
||||
@@ -747,7 +895,14 @@ export function generateExtrudedWall(
|
||||
// Apply CSG subtraction for cutouts (doors/windows)
|
||||
const cutoutBrushes = collectCutoutBrushes(wallNode, childrenNodes, thickness)
|
||||
if (cutoutBrushes.length === 0) {
|
||||
return geometry
|
||||
const splitGeometry = splitGeometryAtHorizontalPlanes(
|
||||
geometry,
|
||||
getWallBandSplitPlanes(wallNode),
|
||||
)
|
||||
splitGeometry.computeVertexNormals()
|
||||
assignWallMaterialGroups(splitGeometry, wallNode, boundaryEdges)
|
||||
ensureRenderableGeometryAttributes(splitGeometry)
|
||||
return splitGeometry
|
||||
}
|
||||
|
||||
// Create wall brush from geometry
|
||||
@@ -777,11 +932,15 @@ export function generateExtrudedWall(
|
||||
}
|
||||
|
||||
const resultGeometry = csgGeometry(resultBrush)
|
||||
resultGeometry.computeVertexNormals()
|
||||
assignWallMaterialGroups(resultGeometry, wallNode, boundaryEdges)
|
||||
ensureRenderableGeometryAttributes(resultGeometry)
|
||||
const splitResultGeometry = splitGeometryAtHorizontalPlanes(
|
||||
resultGeometry,
|
||||
getWallBandSplitPlanes(wallNode),
|
||||
)
|
||||
splitResultGeometry.computeVertexNormals()
|
||||
assignWallMaterialGroups(splitResultGeometry, wallNode, boundaryEdges)
|
||||
ensureRenderableGeometryAttributes(splitResultGeometry)
|
||||
|
||||
return resultGeometry
|
||||
return splitResultGeometry
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user