Viewer render modes: Solid/Rendered + textures + surface-role clay + scene themes + edges (#332)
* viewer: add Phase 1 render-modes foundation (shading/textures/colorPreset state, defaultRender prop, SSGI gating) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: Phase 2 render-modes material-class switch (Lambert in solid, Standard in rendered) Shading-aware material factories (cached per class), reactive selection in renderers via the useViewer(shading) pattern, and dirty-rebuild on toggle for geometry/door systems. Rendered mode output unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: Phase 3a render-modes surface roles + clay palette foundation Adds surfaceRole token to core NodeDefinition, per-kind default roles, ColorPreset palettes + resolveSurfaceColor/createSurfaceRoleMaterial (glazing stays translucent), and the textures-off recolor path for def.geometry kinds (slab/fence/shelf via GeometrySystem.applyDefaultSurfaceRole) + wall. Renderer- based kinds (roof/window/stair/item/column/door/ceiling/elevator) wired in 3b. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: Phase 3b render-modes textures-off recoloring for renderer/system kinds Wires clay role coloring (textures=off) for roof/roof-segment, window, stair/ stair-segment, door, item, column, ceiling, elevator via createSurfaceRoleMaterial, reactive on textures/colorPreset. Per-surface roles: roof top+edge=roof / underside=ceiling; window frame=joinery / glass=glazing; stair+door+elevator= joinery; ceiling=ceiling; column=wall; item=furnishing. textures=on unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * editor: Phase 4a render-modes UI — Solid/Rendered toggle + per-context persistence Per-context shading via renderContext discriminator + shadingByContext (persisted); <Viewer> renderContext prop seeds per-context on mount. Solid/Rendered toggle in the editor action bar + standalone toolbar + command palette. Editor mounts default to renderContext=editor / shading=solid. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: fix window-system glassMaterial type to allow clay glazing reassignment The let was inferred as MeshLambertNodeMaterial from the imported glass constant, so reassigning createSurfaceRoleMaterial('glazing') (returns THREE.Material) failed under tsc --build. Widen the annotation to THREE.Material. Surfaced by the build (check-types had replayed a stale turbo cache). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: Phase 5 lighting — add theme-driven hemisphere light, trim fill directionals 3->2 Adds a sky/ground hemisphere fill (theme-lerped) and drops the second fill directional; the hemisphere covers the shadow-side fill it provided, at one fewer per-fragment directional term (shared by Solid + Rendered). Ambient lowered since the hemisphere now carries soft fill. Intensities are a starting point — tune visually on the gpu-perf overlay. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: tune Solid lighting for more form — stronger hemisphere sky/ground contrast, lower ambient Darker hemisphere ground (#d8d6cf -> #aaa49a) + higher hemisphere intensity and lower ambient so directional shading reads as form and undersides ground without AO. Keeps Solid free of any post-processing pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: biome format render-mode files (lefthook pre-commit) Formatting-only — import wrapping, dep-array wrapping, single-line ternaries — across Phase 2-4a files that weren't biome-clean. No logic changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: scene-theme system — named environment themes (studio/paper/sunset/night/...) New SceneTheme registry (lib/scene-themes.ts) drives lights, background, and tone mapping; lights.tsx refactored data-driven (N directionals + hemisphere + ambient). sceneTheme state (persisted) + cycle-button picker in editor bar + standalone toolbar, importing the registry from the viewer barrel (single source). Default 'studio' reproduces the prior look exactly; app light/dark 'theme' untouched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * editor: Phase 7 — popover-dropdown pickers for render mode + scene theme (editor bar) Replace the shading + scene-theme cycle buttons in viewer-overlay.tsx with DropdownMenu pickers: render mode shows 2 rows (Solid/Rendered) with one-line detail; scene theme lists all themes with a derived color-swatch strip + active check. Imports the registry from the viewer barrel (single source). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * editor: Phase 7 — dropdown pickers in standalone toolbar + export DropdownMenu from barrel Replicate the render-mode + scene-theme dropdown pickers (with swatch strip + active check) to apps/editor's compact toolbar, matching viewer-overlay.tsx. Export DropdownMenu* from the @pascal-app/editor barrel for the standalone app. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer+editor: Phase 8 — crisp geometry edge overlay (off/soft/strong/sketchy) EdgeOverlaySystem draws EdgesGeometry LineSegments over node-backed building meshes (scoped via sceneRegistry, skips zone-layer/hitbox/overlay meshes), rebuilt on geometry-uuid or mode change, line color follows scene-theme background luminance; sketchy = static TSL vertex jitter. New 'edges' state (persisted, default off) + Edges dropdown in editor bar + standalone toolbar. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer+editor: edge overlay — thick lines via Line2, drop sketchy mode Switch EdgeOverlaySystem from LineBasicNodeMaterial (1px hardware cap) to LineSegments2 + Line2NodeMaterial so edges have real screen-space width (soft 1.5px / strong 3px); resolution tracks viewport. EdgeMode is now off/soft/strong (sketchy removed). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: fix edge overlay crash — webgpu Line2NodeMaterial has no settable resolution material.resolution is undefined under WebGPU (the node material reads the viewport internally); optional-chain the .set() call so it no-ops there instead of throwing. Thickness still applies via linewidth. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: edge overlay — weld vertices to fix CSG spiderweb edges + lighter strong Position-only mergeVertices before EdgesGeometry so coplanar triangles from CSG-cut walls (doors/windows) share vertices and their interior edges are suppressed — only opening outlines + silhouettes remain. Strong linewidth 3 -> 2px (was too heavy). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: edge overlay — crease-only extractor to fix CSG spiderweb Replace EdgesGeometry (which always draws unpaired boundary/T-junction edges) with buildCreaseEdges: weld positions, keep only edges shared by exactly two faces whose dihedral exceeds the threshold, drop everything unpaired. CSG-cut walls/slabs are watertight so real corners + opening outlines survive while the interior triangulation fans (coplanar or T-junction) are removed. Open meshes (bare ground plane, billboard leaves) shed their boundary clutter too. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: crease edges — coarser weld (0.1mm->1mm) to recover CSG/extrude seam edges The cap<->side-wall top edge of ExtrudeGeometry walls drifts past 0.1mm after CSG, so it stayed unpaired and was dropped. Weld at ~1mm to pair it into a real crease while staying far below feature size (wall thickness, openings). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: replace geometry edge overlay with screen-space ink (SketchUp look) Port the prototype's screen-space ink into the post-processing pipeline: depth + normal Sobel reading the scene-pass MRT. Crease term (normalized normals, center-vs-neighbour) + distance-independent depth-step term (raw Laplacian / (1-d)² with a noise gate so flat ground stays clean). Topology-agnostic, so it finally handles CSG-cut walls/openings without the spiderweb or missing-edge problems of EdgesGeometry. Driven by the existing edges off/soft/strong mode; MRT now builds when SSGI OR ink is on; ink colour tracks scene-theme luminance. Removes EdgeOverlaySystem + crease-edges (geometry approach). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: soft/strong ink modes + keep editor overlays out of the ink Two adjustments to the screen-space ink pass: 1. Soft vs strong now visibly differ. The edge masks saturate, so the old `intensity` gain did nothing once a line was detected. Replace it with a sample radius (line thickness) + opacity: soft = 1px / 50%, strong = 2px / 100%. `inkedEdges` takes `radius` + `opacity` instead of `intensity`. 2. Editor overlays (gizmos, move handles, tool previews, grid) no longer get inked. The scene pass that feeds the depth/normal MRT now renders only SCENE_LAYER; overlays render in a dedicated pass on OVERLAY_LAYER and are composited on top after the ink + outlines, so they read as crisp UI and never get inked or AO'd. New OVERLAY_LAYER constant in viewer; editor's EDITOR_LAYER re-exports it so the two stay in lockstep. Also moves WallMoveSideHandles (the wall/fence move arrows) onto EDITOR_LAYER — it was the one overlay still on SCENE_LAYER. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: render the grid in the scene pass so geometry occludes it The depth-gate fix couldn't help the grid: its material is depthWrite:false, so it never wrote overlay-pass depth and the "didn't write depth -> keep on top" term forced it on top — hence the floor grid bleeding through walls and objects. A full-floor plane can only be occluded correctly by living in the same depth context as the scene, so move the grid onto its own GRID_LAYER which the scene pass renders (alongside SCENE_LAYER). It's flat and depth-non-writing, so the screen-space ink still ignores it; gizmos/handles stay on OVERLAY_LAYER. The grid camera layer is enabled in custom-camera-controls and disabled on the thumbnail camera so thumbnails stay grid-free, matching EDITOR_LAYER. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: strong ink — match soft's 1px thickness, differ by darkness only Strong at radius 2 read too thick. Soft's 1px line is the nice one, so use it for both modes and let strong distinguish itself purely by being fully solid (opacity 1) vs soft's lighter 50%. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: shadow frustum follows the view + a shadows on/off setting The directional light's ortho shadow camera only covers ±50 around the light target, which was pinned at the origin — so zones far from origin received no shadows no matter where the camera moved. Recentre each shadow-casting light (position + target together, preserving direction) on the view focus every frame: the orbit-controls target when available, else the camera's ground projection. The shadow area now tracks wherever the user looks. Also add a persisted `shadows` toggle (default on) to the viewer store and a "Shadows" switch in the editor settings panel — the dedicated shadows control the render-modes plan deferred. Lights gate castShadow on it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * editor: shadows toggle in the standalone toolbar + a touch more shadow strength The shadows switch I added only lived in the cloud settings panel's Visibility section, which is hidden in the local/standalone editor (no projectId). Add a ShadowsToggle button next to the grid toggle in the standalone toolbar so it's reachable there, matching how Show Grid is exposed in both places. Also push shadow strength partway toward the aesthetic prototype (which runs near-black, no blur): bump the bright-key shadow-intensity cap 0.4 -> 0.55 and tighten shadow-radius 2 -> 1.5. Still softer than aesthetic by design. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * nodes: site ground receives shadows (lit material instead of unlit Basic) The site ground fill used MeshBasicMaterial — unlit, so it could never show the directional shadow, and shadows visibly truncated at the slab edge. Swap it for a lit MeshLambertNodeMaterial with receiveShadow on the mesh; the geometry is the site polygon (slab footprints punched out), so shadows now extend across the whole site and stop at its boundary, which is the desired bound. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * themes: per-theme clay palettes + 2x2 swatch in the theme pickers Each scene theme now carries a clayTints map (wall/floor/ceiling/roof/glazing) giving it a per-surface-role palette — e.g. Mediterranean's blue roof + warm walls. The theme pickers (standalone toolbar + community overlay) now render the aesthetic-style 2x2 swatch of those role tints over the theme background instead of the old 3-colour strip. Data + UI only; wiring the tints into the textures-off surface materials is a separate change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * editor: slimmer theme switcher + cloud-sun icon Shrink the scene-theme toolbar button (w-[8.5rem] -> w-28) so it stops reserving space for "Mediterranean"; the label truncates when it overflows. Swap the palette icon for cloud-sun (atmosphere/lighting, distinct from the app light/dark Sun-Moon toggle) in both the standalone toolbar and the community overlay. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * editor: theme switcher icon -> swatch-book Swap the scene-theme icon from cloud-sun to swatch-book in both the standalone toolbar and the community overlay. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * themes: colour untextured building surfaces by the active scene theme Untextured walls/roof/slab/ceiling now take the active theme's per-role colour (theme.clayTints[role], falling back to the colour preset) in BOTH textures modes. The textures toggle only governs surfaces that actually have an explicit material/preset — those still show their texture when textures are on. This is what makes e.g. Mediterranean read as a blue roof + warm walls instead of the old hardcoded white/grey defaults. - materials.ts: resolveSurfaceColor / createSurfaceRoleMaterial take an optional sceneThemeId (theme tint ?? preset palette); theme folded into the cache key. - wall-materials, roof-materials, slab/geometry, ceiling/renderer: the untextured fallback now resolves to the themed role colour instead of white/grey, in both modes; theme threaded into each builder + material cache key. - wall-cutout: now reads textures/colorPreset/sceneTheme and re-applies wall materials when any change (previously it ignored textures/colorPreset entirely). - geometry-system: threads sceneTheme into the generic surface-role path + rebuild effect. Renderers/preview call sites thread sceneTheme through. Doors/windows/stairs/columns/items still use their existing defaults — a follow-up pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(slab): recessed (negative-elevation) slabs extrude downward again The registry geometry builder created the slab mesh at Y=0 without applying the negative-elevation offset, so recessed slabs rendered above the floor plane (pool geometry is built locally with its floor cap at Y=0 and walls rising to Y=|elevation|, so the mesh must be shifted down by `elevation` to recess). The runtime slab-system already did this; the static builder path didn't. Mirror it: shift mesh.position.y by elevation when negative. Positive elevation unchanged. Unrelated to render modes — bundled into this branch's PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(roof): legacy roofs render again — migration wrote invalid wallHeight 0 Legacy roof nodes (old format, no `children`) were migrated with a hardcoded `wallHeight: 0`. With zero wall height the eave height (`wallHeight - autoDrop`) went negative in getRoofSegmentBrushes, producing geometrically invalid brushes; three-bvh-csg then spammed "TriangleClipper: Coplanar clip not handled" every frame and emitted NaN positions, so the merged roof geometry failed computeBoundingSphere and never rendered. - core/use-scene migration: wallHeight 0 -> 0.5 (the RoofSegmentNode schema default), so migrated segments have a valid wall height. - roof-system: clamp eave height to >= 0.01 so an intentional wallHeight 0 can never yield a negative eave, and guard updateMergedRoofGeometry so a CSG result with NaN positions is discarded (keep the last good mesh, warn once per roof) instead of poisoning the buffer + spamming the console. Unrelated to render modes — bundled into this branch's PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(column): columns cast and receive shadows Column meshes (box, beam, cylinder, sphere, torus) rendered without castShadow/ receiveShadow, so columns neither dropped a shadow nor caught one — unlike walls, slabs and roofs. Set both on all column shape meshes. Unrelated to render modes — bundled into this branch's PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: default edges to soft Editor defaults are now solid shading / studio theme / soft edges / shadows on. Shading (solid, via EDITOR_DEFAULT_RENDER), theme (studio) and shadows (on) were already the defaults; edges was 'off' — make 'soft' the default. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * themes: fold light/dark into the scene theme (remove the separate toggle) The viewer had two overlapping appearance controls: a light/dark `theme` toggle AND scene themes (which already drive the 3D background + lights). They conflicted — e.g. Night/Twilight are dark themes, but the light/dark toggle was an independent axis still tinting the 2D scene chrome. Unify on the scene theme: add an explicit `appearance: 'light' | 'dark'` to each SceneTheme (twilight/night = dark, the rest = light) and drive everything the old toggle drove off it — canvas backdrop, grid line colours, measurement-label/ cursor/site-edge contrast, the site ground fill, the ground occluder, and the mobile viewer bg. The editor UI chrome is unaffected (always dark via a fixed body class). Removes the `theme`/`setTheme` store state (+ persistence) and every light/dark toggle UI: the standalone toolbar Sun/Moon button, the community overlay theme switch, the command-palette command, and the ifc-converter preview toolbar button. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * themes: per-theme ground colour + add the "Verdant" nature theme - Add a `ground` colour to every SceneTheme and drive the site ground fill + the infinite ground-occluder off it (instead of the binary isDark ? #1f2433 : #fafafa). Dark themes now get a lit mid-tone ground (twilight #4a4566, night #2b3247) so the ground reads as ground rather than going near-black. - Add a new green/nature scene theme "Verdant": soft green sky + lit, with a green roof clay tint and mossy ground. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * wiki: document the surface-colour / theme system The colour-per-node/renderer/system model from the render-modes work was undocumented. Add wiki/architecture/materials-and-themes.md covering surface roles, colour presets, the textures axis, scene themes (appearance / ground / clay tints), and the "untextured surfaces are theme-coloured in both modes" invariant + where each kind wires it. Also fix two pages that the same work made stale: - node-definitions: geometry builders receive (shading, textures, colorPreset, sceneTheme); document the `surfaceRole` token + applyDefaultSurfaceRole. - layers: OVERLAY_LAYER (1, viewer) with EDITOR_LAYER now its alias, the new GRID_LAYER (3, rendered in the scene pass for depth occlusion), and the overlay pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * nodes: wire #330 roof-accessory kinds into the surface-role/theme colours PR #330's new kinds (chimney, dormer, skylight, solar-panel, ridge-vent, box-vent) use custom renderers, so the generic textures-off recolour path never reached them — they fell back to hardcoded colours. Wire each renderer into the render-modes system: read shading/textures/colorPreset/sceneTheme and resolve untextured surfaces via createSurfaceRoleMaterial (and force the role colour when textures are off), matching column/ceiling. Roles: chimney body→wall / cap→roof; dormer wall→wall, roof→roof, glass→glazing, frame→joinery; skylight glass→glazing / frame→joinery; ridge-vent + box-vent→roof; solar-panel frame→roof (the dark product-specific cell face is left as-is). Each definition also gets its dominant `surfaceRole` token. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(roof): legacy-roof migration must use a non-zero wallHeight The render-modes/#330 merge left the legacy roof→roof-segment migration writing `wallHeight: 0`. With #330's pitch model that builds a flat, zero-volume wall CSG brush, which three-bvh-csg can't clip ("Coplanar clip not handled") and yields NaN positions — so the migrated old roof never renders. Use the schema default 0.5 (what new roofs use), giving a valid wall. The eave clamp + merged- geometry NaN guard added earlier stay as defense-in-depth. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(roof): guard slope frame against missing/NaN pitch (no more NaN geometry) getSegmentSlopeFrame used `pitch <= 0` to detect flat/zero-pitch, but an undefined or NaN pitch (a segment from an older migration that set `roofHeight` instead of `pitch`, or stale persisted data) slips past that check and computes Math.tan(NaN) → NaN tanTheta/activeRh → NaN segment geometry → the merged-roof CSG spews "Coplanar clip not handled" and NaN positions, so the roof never renders. Use `!(pitch > 0)` so any non-positive/non-finite pitch resolves to the flat frame. Self-heals bad data regardless of how the segment was produced. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(roof): migration guarantees a positive pitch for every roof-segment Segments saved with neither a valid pitch nor a roofHeight (older/partial saves, e.g. landing home-graph) fell through the legacy roofHeight->pitch branch, leaving pitch undefined. The slope-frame guard then resolved them to a flat frame, so the roof rendered as a slab instead of pitched. Branch 2b now normalises any segment lacking a valid pitch: derive from roofHeight when present, else fall back to the schema default (40deg). The migration result is cast (not zod-parsed), so this is the only place the default lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
87384cfbab
commit
bb5ce68254
@@ -6,9 +6,9 @@ import type {
|
||||
BuildingNode,
|
||||
CeilingNode,
|
||||
ChimneyNode,
|
||||
DormerNode,
|
||||
ColumnNode,
|
||||
DoorNode,
|
||||
DormerNode,
|
||||
ElevatorNode,
|
||||
FenceNode,
|
||||
GuideNode,
|
||||
@@ -21,8 +21,8 @@ import type {
|
||||
ShelfNode,
|
||||
SiteNode,
|
||||
SkylightNode,
|
||||
SolarPanelNode,
|
||||
SlabNode,
|
||||
SolarPanelNode,
|
||||
SpawnNode,
|
||||
StairNode,
|
||||
StairSegmentNode,
|
||||
|
||||
@@ -70,6 +70,7 @@ export type {
|
||||
SnapServicesLike,
|
||||
SurfacePoint,
|
||||
SurfaceQuery,
|
||||
SurfaceRole,
|
||||
SurfacesConfig,
|
||||
SystemContribution,
|
||||
ToolHint,
|
||||
|
||||
@@ -512,11 +512,21 @@ export type Plugin = {
|
||||
|
||||
export type AnyNodeDefinition = NodeDefinition<ZodObject<any>>
|
||||
|
||||
export type SurfaceRole =
|
||||
| 'wall'
|
||||
| 'floor'
|
||||
| 'ceiling'
|
||||
| 'roof'
|
||||
| 'joinery'
|
||||
| 'glazing'
|
||||
| 'furnishing'
|
||||
|
||||
export type NodeDefinition<S extends ZodObject<any>> = {
|
||||
kind: string
|
||||
schemaVersion: number
|
||||
schema: S
|
||||
category: NodeCategory
|
||||
surfaceRole?: SurfaceRole
|
||||
|
||||
defaults: () => Omit<z.infer<S>, 'id' | 'type'>
|
||||
migrate?: Record<number, (old: unknown) => unknown>
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
// Base
|
||||
|
||||
export {
|
||||
SOLAR_PANEL_PRESET_LABELS,
|
||||
SOLAR_PANEL_PRESETS,
|
||||
type SolarPanelPresetDims,
|
||||
SolarPanelPresetKey,
|
||||
} from '../solar-panel-presets'
|
||||
export { BaseNode, generateId, Material, nodeType, objectId } from './base'
|
||||
// Camera
|
||||
export { CameraSchema } from './camera'
|
||||
@@ -28,12 +35,6 @@ export { BoxVentNode } from './nodes/box-vent'
|
||||
export { BuildingNode } from './nodes/building'
|
||||
export { CeilingNode } from './nodes/ceiling'
|
||||
export { ChimneyMaterialRole, ChimneyNode } from './nodes/chimney'
|
||||
export {
|
||||
DormerNode,
|
||||
type DormerSurfaceMaterialRole,
|
||||
type DormerSurfaceMaterialSpec,
|
||||
getEffectiveDormerSurfaceMaterial,
|
||||
} from './nodes/dormer'
|
||||
export {
|
||||
COLUMN_PRESETS,
|
||||
ColumnBaseStyle,
|
||||
@@ -50,6 +51,12 @@ export {
|
||||
ColumnSupportStyle,
|
||||
} from './nodes/column'
|
||||
export { DoorNode, DoorSegment } from './nodes/door'
|
||||
export {
|
||||
DormerNode,
|
||||
type DormerSurfaceMaterialRole,
|
||||
type DormerSurfaceMaterialSpec,
|
||||
getEffectiveDormerSurfaceMaterial,
|
||||
} from './nodes/dormer'
|
||||
export {
|
||||
ElevatorDoorPanelStyle,
|
||||
ElevatorDoorStyle,
|
||||
@@ -77,6 +84,8 @@ export {
|
||||
LOW_PROFILE_ITEM_SURFACE_MAX_HEIGHT,
|
||||
} from './nodes/item'
|
||||
export { LevelNode } from './nodes/level'
|
||||
// Nodes
|
||||
export { RidgeVentNode } from './nodes/ridge-vent'
|
||||
export type { RoofSurfaceMaterialRole, RoofSurfaceMaterialSpec } from './nodes/roof'
|
||||
export { getEffectiveRoofSurfaceMaterial, RoofNode } from './nodes/roof'
|
||||
export type {
|
||||
@@ -95,9 +104,8 @@ export {
|
||||
RoofType,
|
||||
} from './nodes/roof-segment'
|
||||
export { ScanNode } from './nodes/scan'
|
||||
// Nodes
|
||||
export { RidgeVentNode } from './nodes/ridge-vent'
|
||||
export { ShelfNode } from './nodes/shelf'
|
||||
export { SiteNode } from './nodes/site'
|
||||
export {
|
||||
SKYLIGHT_TYPE_ORDER,
|
||||
SKYLIGHT_TYPE_PRESETS,
|
||||
@@ -105,21 +113,14 @@ export {
|
||||
SkylightNode,
|
||||
SkylightOpeningSide,
|
||||
SkylightSlideDirection,
|
||||
type SkylightTypePreset,
|
||||
SkylightType,
|
||||
type SkylightTypePreset,
|
||||
} from './nodes/skylight'
|
||||
export { SlabNode } from './nodes/slab'
|
||||
export {
|
||||
SolarPanelMaterialRole,
|
||||
SolarPanelNode,
|
||||
} from './nodes/solar-panel'
|
||||
export {
|
||||
SOLAR_PANEL_PRESET_LABELS,
|
||||
SOLAR_PANEL_PRESETS,
|
||||
type SolarPanelPresetDims,
|
||||
SolarPanelPresetKey,
|
||||
} from '../solar-panel-presets'
|
||||
export { SiteNode } from './nodes/site'
|
||||
export { SlabNode } from './nodes/slab'
|
||||
export { SpawnNode } from './nodes/spawn'
|
||||
export type { StairSurfaceMaterialRole, StairSurfaceMaterialSpec } from './nodes/stair'
|
||||
export {
|
||||
|
||||
@@ -164,8 +164,7 @@ function withRatioDefaults(input: PitchInputs): PitchInputs & ShapeRatios {
|
||||
mansardSteepHeightRatio:
|
||||
input.mansardSteepHeightRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepHeightRatio,
|
||||
dutchHipWidthRatio: input.dutchHipWidthRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio,
|
||||
dutchHipHeightRatio:
|
||||
input.dutchHipHeightRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipHeightRatio,
|
||||
dutchHipHeightRatio: input.dutchHipHeightRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipHeightRatio,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,12 +225,15 @@ export type SegmentSlopeFrame = {
|
||||
* silently drifted when a new roof type was added.
|
||||
*/
|
||||
export function getSegmentSlopeFrame(
|
||||
node: Pick<RoofSegmentNode, 'roofType' | 'pitch' | 'width' | 'depth'> &
|
||||
Partial<ShapeRatios>,
|
||||
node: Pick<RoofSegmentNode, 'roofType' | 'pitch' | 'width' | 'depth'> & Partial<ShapeRatios>,
|
||||
): SegmentSlopeFrame {
|
||||
const ratios = withRatioDefaults(node)
|
||||
const run = getPrimarySlopeRun(ratios)
|
||||
if (node.roofType === 'flat' || node.pitch <= 0) {
|
||||
// `!(pitch > 0)` (not `pitch <= 0`) so a missing/NaN pitch — e.g. a segment
|
||||
// from an older migration that only set `roofHeight`, or stale persisted data —
|
||||
// resolves to a flat frame instead of computing `Math.tan(NaN)` → NaN geometry,
|
||||
// which poisons the merged-roof CSG ("Coplanar clip not handled" + NaN positions).
|
||||
if (node.roofType === 'flat' || !(node.pitch > 0)) {
|
||||
return { run, rise: 0, tanTheta: 0, cosTheta: 1, sinTheta: 0, activeRh: 0 }
|
||||
}
|
||||
const pitchRad = (node.pitch * Math.PI) / 180
|
||||
@@ -247,9 +249,7 @@ export function getSegmentSlopeFrame(
|
||||
* The eave-to-peak height of the assembled segment, derived from pitch +
|
||||
* footprint + roofType. Replaces the legacy `roofHeight` field on the node.
|
||||
*/
|
||||
export function getActiveRoofHeight(
|
||||
node: Parameters<typeof getSegmentSlopeFrame>[0],
|
||||
): number {
|
||||
export function getActiveRoofHeight(node: Parameters<typeof getSegmentSlopeFrame>[0]): number {
|
||||
return getSegmentSlopeFrame(node).activeRh
|
||||
}
|
||||
|
||||
@@ -258,9 +258,7 @@ export function getActiveRoofHeight(
|
||||
* `roofHeight` value would correspond to. Used by the scene migration.
|
||||
* Ratio overrides are optional and default to the shape defaults.
|
||||
*/
|
||||
export function getPitchFromActiveRoofHeight(
|
||||
input: PitchInputs & { roofHeight: number },
|
||||
): number {
|
||||
export function getPitchFromActiveRoofHeight(input: PitchInputs & { roofHeight: number }): number {
|
||||
if (input.roofType === 'flat' || input.roofHeight <= 0) return 0
|
||||
const ratios = withRatioDefaults(input)
|
||||
const run = getPrimarySlopeRun(ratios)
|
||||
|
||||
@@ -42,43 +42,108 @@ export type SkylightTypePreset = {
|
||||
export const SKYLIGHT_TYPE_PRESETS = {
|
||||
flat: {
|
||||
label: 'Flat roof skylight',
|
||||
width: 0.9, height: 1.2, frameThickness: 0.055, frameDepth: 0.08,
|
||||
glassThickness: 0.012, curb: true, curbHeight: 0.08, cutoutOffset: 0,
|
||||
lanternHeight: 0.25, lanternTopScale: 0, openingAngle: 0,
|
||||
openingSide: 'top', operationState: 0, motorHousing: false,
|
||||
slideFraction: 0, slideDirection: 'z', trackWidth: 0.04, motorHousingSize: 0.08,
|
||||
width: 0.9,
|
||||
height: 1.2,
|
||||
frameThickness: 0.055,
|
||||
frameDepth: 0.08,
|
||||
glassThickness: 0.012,
|
||||
curb: true,
|
||||
curbHeight: 0.08,
|
||||
cutoutOffset: 0,
|
||||
lanternHeight: 0.25,
|
||||
lanternTopScale: 0,
|
||||
openingAngle: 0,
|
||||
openingSide: 'top',
|
||||
operationState: 0,
|
||||
motorHousing: false,
|
||||
slideFraction: 0,
|
||||
slideDirection: 'z',
|
||||
trackWidth: 0.04,
|
||||
motorHousingSize: 0.08,
|
||||
},
|
||||
'walk-on': {
|
||||
label: 'Walk-on rooflight',
|
||||
width: 1.2, height: 1.8, frameThickness: 0.035, frameDepth: 0.045,
|
||||
glassThickness: 0.04, curb: false, curbHeight: 0, cutoutOffset: 0,
|
||||
lanternHeight: 0.25, lanternTopScale: 0, openingAngle: 0,
|
||||
openingSide: 'top', operationState: 0, motorHousing: false,
|
||||
slideFraction: 0, slideDirection: 'z', trackWidth: 0.04, motorHousingSize: 0.08,
|
||||
width: 1.2,
|
||||
height: 1.8,
|
||||
frameThickness: 0.035,
|
||||
frameDepth: 0.045,
|
||||
glassThickness: 0.04,
|
||||
curb: false,
|
||||
curbHeight: 0,
|
||||
cutoutOffset: 0,
|
||||
lanternHeight: 0.25,
|
||||
lanternTopScale: 0,
|
||||
openingAngle: 0,
|
||||
openingSide: 'top',
|
||||
operationState: 0,
|
||||
motorHousing: false,
|
||||
slideFraction: 0,
|
||||
slideDirection: 'z',
|
||||
trackWidth: 0.04,
|
||||
motorHousingSize: 0.08,
|
||||
},
|
||||
lantern: {
|
||||
label: 'Roof lantern',
|
||||
width: 1.4, height: 1.4, frameThickness: 0.06, frameDepth: 0.08,
|
||||
glassThickness: 0.012, curb: true, curbHeight: 0.16, cutoutOffset: 0,
|
||||
lanternHeight: 0.45, lanternTopScale: 0, openingAngle: 0,
|
||||
openingSide: 'top', operationState: 0, motorHousing: false,
|
||||
slideFraction: 0, slideDirection: 'z', trackWidth: 0.04, motorHousingSize: 0.08,
|
||||
width: 1.4,
|
||||
height: 1.4,
|
||||
frameThickness: 0.06,
|
||||
frameDepth: 0.08,
|
||||
glassThickness: 0.012,
|
||||
curb: true,
|
||||
curbHeight: 0.16,
|
||||
cutoutOffset: 0,
|
||||
lanternHeight: 0.45,
|
||||
lanternTopScale: 0,
|
||||
openingAngle: 0,
|
||||
openingSide: 'top',
|
||||
operationState: 0,
|
||||
motorHousing: false,
|
||||
slideFraction: 0,
|
||||
slideDirection: 'z',
|
||||
trackWidth: 0.04,
|
||||
motorHousingSize: 0.08,
|
||||
},
|
||||
opening: {
|
||||
label: 'Opening skylight',
|
||||
width: 0.9, height: 1.2, frameThickness: 0.06, frameDepth: 0.035,
|
||||
glassThickness: 0.014, curb: true, curbHeight: 0.1, cutoutOffset: 0,
|
||||
lanternHeight: 0.25, lanternTopScale: 0, openingAngle: Math.PI / 8,
|
||||
openingSide: 'top', operationState: 1, motorHousing: false,
|
||||
slideFraction: 0, slideDirection: 'z', trackWidth: 0.04, motorHousingSize: 0.08,
|
||||
width: 0.9,
|
||||
height: 1.2,
|
||||
frameThickness: 0.06,
|
||||
frameDepth: 0.035,
|
||||
glassThickness: 0.014,
|
||||
curb: true,
|
||||
curbHeight: 0.1,
|
||||
cutoutOffset: 0,
|
||||
lanternHeight: 0.25,
|
||||
lanternTopScale: 0,
|
||||
openingAngle: Math.PI / 8,
|
||||
openingSide: 'top',
|
||||
operationState: 1,
|
||||
motorHousing: false,
|
||||
slideFraction: 0,
|
||||
slideDirection: 'z',
|
||||
trackWidth: 0.04,
|
||||
motorHousingSize: 0.08,
|
||||
},
|
||||
sliding: {
|
||||
label: 'Sliding skylight',
|
||||
width: 1.4, height: 1.1, frameThickness: 0.055, frameDepth: 0.08,
|
||||
glassThickness: 0.012, curb: true, curbHeight: 0.08, cutoutOffset: 0,
|
||||
lanternHeight: 0.25, lanternTopScale: 0, openingAngle: 0,
|
||||
openingSide: 'top', operationState: 0.35, motorHousing: false,
|
||||
slideFraction: 0.35, slideDirection: 'x', trackWidth: 0.045, motorHousingSize: 0.08,
|
||||
width: 1.4,
|
||||
height: 1.1,
|
||||
frameThickness: 0.055,
|
||||
frameDepth: 0.08,
|
||||
glassThickness: 0.012,
|
||||
curb: true,
|
||||
curbHeight: 0.08,
|
||||
cutoutOffset: 0,
|
||||
lanternHeight: 0.25,
|
||||
lanternTopScale: 0,
|
||||
openingAngle: 0,
|
||||
openingSide: 'top',
|
||||
operationState: 0.35,
|
||||
motorHousing: false,
|
||||
slideFraction: 0.35,
|
||||
slideDirection: 'x',
|
||||
trackWidth: 0.045,
|
||||
motorHousingSize: 0.08,
|
||||
},
|
||||
} as const satisfies Record<SkylightType, SkylightTypePreset>
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ import { BoxVentNode } from './nodes/box-vent'
|
||||
import { BuildingNode } from './nodes/building'
|
||||
import { CeilingNode } from './nodes/ceiling'
|
||||
import { ChimneyNode } from './nodes/chimney'
|
||||
import { DormerNode } from './nodes/dormer'
|
||||
import { ColumnNode } from './nodes/column'
|
||||
import { DoorNode } from './nodes/door'
|
||||
import { DormerNode } from './nodes/dormer'
|
||||
import { ElevatorNode } from './nodes/elevator'
|
||||
import { FenceNode } from './nodes/fence'
|
||||
import { GuideNode } from './nodes/guide'
|
||||
@@ -16,10 +16,10 @@ import { RoofNode } from './nodes/roof'
|
||||
import { RoofSegmentNode } from './nodes/roof-segment'
|
||||
import { ScanNode } from './nodes/scan'
|
||||
import { ShelfNode } from './nodes/shelf'
|
||||
import { SkylightNode } from './nodes/skylight'
|
||||
import { SolarPanelNode } from './nodes/solar-panel'
|
||||
import { SiteNode } from './nodes/site'
|
||||
import { SkylightNode } from './nodes/skylight'
|
||||
import { SlabNode } from './nodes/slab'
|
||||
import { SolarPanelNode } from './nodes/solar-panel'
|
||||
import { SpawnNode } from './nodes/spawn'
|
||||
import { StairNode } from './nodes/stair'
|
||||
import { StairSegmentNode } from './nodes/stair-segment'
|
||||
|
||||
@@ -97,17 +97,15 @@ function vent(): AnyNode {
|
||||
|
||||
describe('node-actions reparent — repeated segment-hopping', () => {
|
||||
beforeEach(() => {
|
||||
useScene.setState(
|
||||
{
|
||||
nodes: {
|
||||
[ROOF_ID]: makeRoof(),
|
||||
[SEG_A_ID]: makeSegment(SEG_A_ID, [VENT_ID]),
|
||||
[SEG_B_ID]: makeSegment(SEG_B_ID, []),
|
||||
[VENT_ID]: makeVent(SEG_A_ID),
|
||||
},
|
||||
rootNodeIds: [ROOF_ID],
|
||||
} as never,
|
||||
)
|
||||
useScene.setState({
|
||||
nodes: {
|
||||
[ROOF_ID]: makeRoof(),
|
||||
[SEG_A_ID]: makeSegment(SEG_A_ID, [VENT_ID]),
|
||||
[SEG_B_ID]: makeSegment(SEG_B_ID, []),
|
||||
[VENT_ID]: makeVent(SEG_A_ID),
|
||||
},
|
||||
rootNodeIds: [ROOF_ID],
|
||||
} as never)
|
||||
useScene.temporal.getState().clear()
|
||||
})
|
||||
|
||||
@@ -166,48 +164,41 @@ describe('node-actions reparent — repeated segment-hopping', () => {
|
||||
['dormer', 'dorm_x'],
|
||||
['solar-panel', 'sp_x'],
|
||||
['ridge-vent', 'rvent_x'],
|
||||
])(
|
||||
'A→B→A→B for %s: chimney-style auto-reparent leaves children clean',
|
||||
(type, idStr) => {
|
||||
const id = idStr as AnyNodeId
|
||||
useScene.setState(
|
||||
{
|
||||
nodes: {
|
||||
[ROOF_ID]: makeRoof(),
|
||||
[SEG_A_ID]: makeSegment(SEG_A_ID, [id]),
|
||||
[SEG_B_ID]: makeSegment(SEG_B_ID, []),
|
||||
[id]: {
|
||||
id,
|
||||
type,
|
||||
parentId: SEG_A_ID,
|
||||
roofSegmentId: SEG_A_ID,
|
||||
object: 'node',
|
||||
visible: true,
|
||||
name: '',
|
||||
metadata: {},
|
||||
position: [0, 0, 0],
|
||||
rotation: 0,
|
||||
} as unknown as AnyNode,
|
||||
},
|
||||
rootNodeIds: [ROOF_ID],
|
||||
} as never,
|
||||
)
|
||||
])('A→B→A→B for %s: chimney-style auto-reparent leaves children clean', (type, idStr) => {
|
||||
const id = idStr as AnyNodeId
|
||||
useScene.setState({
|
||||
nodes: {
|
||||
[ROOF_ID]: makeRoof(),
|
||||
[SEG_A_ID]: makeSegment(SEG_A_ID, [id]),
|
||||
[SEG_B_ID]: makeSegment(SEG_B_ID, []),
|
||||
[id]: {
|
||||
id,
|
||||
type,
|
||||
parentId: SEG_A_ID,
|
||||
roofSegmentId: SEG_A_ID,
|
||||
object: 'node',
|
||||
visible: true,
|
||||
name: '',
|
||||
metadata: {},
|
||||
position: [0, 0, 0],
|
||||
rotation: 0,
|
||||
} as unknown as AnyNode,
|
||||
},
|
||||
rootNodeIds: [ROOF_ID],
|
||||
} as never)
|
||||
|
||||
const hop = (to: AnyNodeId) =>
|
||||
useScene
|
||||
.getState()
|
||||
.updateNode(id, { parentId: to, roofSegmentId: to } as Partial<AnyNode>)
|
||||
const hop = (to: AnyNodeId) =>
|
||||
useScene.getState().updateNode(id, { parentId: to, roofSegmentId: to } as Partial<AnyNode>)
|
||||
|
||||
hop(SEG_B_ID)
|
||||
hop(SEG_A_ID)
|
||||
hop(SEG_B_ID)
|
||||
hop(SEG_B_ID)
|
||||
hop(SEG_A_ID)
|
||||
hop(SEG_B_ID)
|
||||
|
||||
expect(childrenOf(SEG_A_ID)).toEqual([])
|
||||
expect(childrenOf(SEG_B_ID)).toEqual([id])
|
||||
const node = useScene.getState().nodes[id] as { parentId: AnyNodeId }
|
||||
expect(node.parentId).toBe(SEG_B_ID)
|
||||
},
|
||||
)
|
||||
expect(childrenOf(SEG_A_ID)).toEqual([])
|
||||
expect(childrenOf(SEG_B_ID)).toEqual([id])
|
||||
const node = useScene.getState().nodes[id] as { parentId: AnyNodeId }
|
||||
expect(node.parentId).toBe(SEG_B_ID)
|
||||
})
|
||||
|
||||
test('A→B→A→B with redundant manual children edits (vent move-tool pattern, pre-fix)', () => {
|
||||
// Reproduces the box-vent / ridge-vent move-tool order: manual
|
||||
|
||||
@@ -9,6 +9,7 @@ import { generateCollectionId } from '../schema/collections'
|
||||
import { LevelNode } from '../schema/nodes/level'
|
||||
import {
|
||||
getPitchFromActiveRoofHeight,
|
||||
type RoofSegmentNode,
|
||||
type RoofType,
|
||||
} from '../schema/nodes/roof-segment'
|
||||
import { SiteNode } from '../schema/nodes/site'
|
||||
@@ -316,7 +317,10 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
|
||||
roofType: 'gable',
|
||||
width: segWidth,
|
||||
depth: segDepth,
|
||||
wallHeight: 0,
|
||||
// Schema default (0.5), NOT 0: a zero-height wall builds a flat,
|
||||
// degenerate CSG brush → "Coplanar clip not handled" + NaN geometry, so
|
||||
// the migrated legacy roof never renders. New roofs use 0.5 too.
|
||||
wallHeight: 0.5,
|
||||
pitch: getPitchFromActiveRoofHeight({
|
||||
roofType: 'gable',
|
||||
width: segWidth,
|
||||
@@ -336,23 +340,28 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
|
||||
}
|
||||
}
|
||||
|
||||
// 2b. roof-segment: legacy roofHeight → pitch.
|
||||
// Saved scenes wrote `roofHeight` in metres; the schema now stores
|
||||
// `pitch` in degrees. Convert once and drop the old field so future
|
||||
// loads skip this branch.
|
||||
if (node.type === 'roof-segment' && 'roofHeight' in node && !('pitch' in node)) {
|
||||
const { roofHeight, ...rest } = node
|
||||
const width = typeof node.width === 'number' ? node.width : 8
|
||||
const depth = typeof node.depth === 'number' ? node.depth : 6
|
||||
const roofType = (typeof node.roofType === 'string' ? node.roofType : 'gable') as RoofType
|
||||
patchedNodes[id] = {
|
||||
...rest,
|
||||
pitch: getPitchFromActiveRoofHeight({
|
||||
roofType,
|
||||
width,
|
||||
depth,
|
||||
roofHeight: typeof roofHeight === 'number' ? roofHeight : 2.5,
|
||||
}),
|
||||
// 2b. roof-segment: guarantee a valid positive pitch (degrees).
|
||||
// Saved scenes wrote `roofHeight` in metres; the schema now stores `pitch`
|
||||
// in degrees. Convert the legacy field when present, and — crucially — fall
|
||||
// back to the schema default for any segment that carries no usable pitch or
|
||||
// roofHeight (older/partial saves). Without this, the slope-frame guard
|
||||
// resolves a missing pitch to a FLAT frame, so the roof renders as a slab.
|
||||
// The migration result is cast, not zod-parsed, so the schema default never
|
||||
// applies on its own — this branch is the only place it lands.
|
||||
if (node.type === 'roof-segment') {
|
||||
const currentPitch = (node as { pitch?: unknown }).pitch
|
||||
const hasValidPitch = typeof currentPitch === 'number' && currentPitch > 0
|
||||
if (!hasValidPitch) {
|
||||
const { roofHeight, ...rest } = node as RoofSegmentNode & { roofHeight?: unknown }
|
||||
const width = typeof node.width === 'number' ? node.width : 8
|
||||
const depth = typeof node.depth === 'number' ? node.depth : 6
|
||||
const roofType = (typeof node.roofType === 'string' ? node.roofType : 'gable') as RoofType
|
||||
const derived =
|
||||
typeof roofHeight === 'number' && roofHeight > 0
|
||||
? getPitchFromActiveRoofHeight({ roofType, width, depth, roofHeight })
|
||||
: 0
|
||||
// 40° matches the RoofSegmentNode schema default.
|
||||
patchedNodes[id] = { ...rest, pitch: derived > 0 ? derived : 40 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer, ZONE_LAYER } from '@pascal-app/viewer'
|
||||
import { GRID_LAYER, useViewer, ZONE_LAYER } from '@pascal-app/viewer'
|
||||
import { CameraControls, CameraControlsImpl } from '@react-three/drei'
|
||||
import { useThree } from '@react-three/fiber'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
@@ -40,6 +40,7 @@ export const CustomCameraControls = () => {
|
||||
const raycaster = useThree((state) => state.raycaster)
|
||||
useEffect(() => {
|
||||
camera.layers.enable(EDITOR_LAYER)
|
||||
camera.layers.enable(GRID_LAYER)
|
||||
raycaster.layers.enable(EDITOR_LAYER)
|
||||
raycaster.layers.enable(ZONE_LAYER)
|
||||
}, [camera, raycaster])
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { getSceneTheme, useViewer } from '@pascal-app/viewer'
|
||||
import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import { MobileTabBar } from '../ui/sidebar/mobile-tab-bar'
|
||||
@@ -56,8 +56,8 @@ export function EditorLayoutMobile({
|
||||
const activePanel = useEditor((s) => s.activeSidebarPanel)
|
||||
const setActivePanel = useEditor((s) => s.setActiveSidebarPanel)
|
||||
const panelSheetHeight = useEditor((s) => s.mobilePanelSheetHeight)
|
||||
const theme = useViewer((s) => s.theme)
|
||||
const viewerBg = theme === 'light' ? VIEWER_BG_LIGHT : VIEWER_BG_DARK
|
||||
const isDark = useViewer((s) => getSceneTheme(s.sceneTheme).appearance === 'dark')
|
||||
const viewerBg = isDark ? VIEWER_BG_DARK : VIEWER_BG_LIGHT
|
||||
|
||||
const middleRef = useRef<HTMLDivElement>(null)
|
||||
const sheetRef = useRef<BottomSheetHandle>(null)
|
||||
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
ZoneNode as ZoneNodeSchema,
|
||||
type ZoneNode as ZoneNodeType,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { getSceneTheme, useViewer } from '@pascal-app/viewer'
|
||||
import { Command, Ruler } from 'lucide-react'
|
||||
import {
|
||||
memo,
|
||||
@@ -3896,7 +3896,7 @@ export function FloorplanPanel() {
|
||||
const previewSelectedIds = useViewer((state) => state.previewSelectedIds)
|
||||
const setSelection = useViewer((state) => state.setSelection)
|
||||
const setPreviewSelectedIds = useViewer((state) => state.setPreviewSelectedIds)
|
||||
const theme = useViewer((state) => state.theme)
|
||||
const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
|
||||
const unit = useViewer((state) => state.unit)
|
||||
const showGrid = useViewer((state) => state.showGrid)
|
||||
const showGuides = useViewer((state) => state.showGuides)
|
||||
@@ -5234,7 +5234,7 @@ export function FloorplanPanel() {
|
||||
|
||||
const palette = useMemo(
|
||||
() =>
|
||||
theme === 'dark'
|
||||
isDark
|
||||
? {
|
||||
surface: '#0a0e1b',
|
||||
minorGrid: '#334155',
|
||||
@@ -5347,9 +5347,9 @@ export function FloorplanPanel() {
|
||||
curveHandleStroke: '#0f766e',
|
||||
curveHandleHoverStroke: '#14b8a6',
|
||||
},
|
||||
[theme],
|
||||
[isDark],
|
||||
)
|
||||
const wallSelectionHatchId = useMemo(() => `floorplan-wall-selection-hatch-${theme}`, [theme])
|
||||
const wallSelectionHatchId = useMemo(() => `floorplan-wall-selection-hatch-${isDark}`, [isDark])
|
||||
// Subset of the legacy palette surfaced to registry-driven kinds via
|
||||
// <FloorplanRenderProvider>. Mirrors `FloorplanPalette` in `@pascal-app/
|
||||
// core` — keep slot names + meanings in sync.
|
||||
@@ -5368,12 +5368,12 @@ export function FloorplanPanel() {
|
||||
curveHandleStroke: palette.curveHandleStroke,
|
||||
curveHandleHoverStroke: palette.curveHandleHoverStroke,
|
||||
measurementStroke: palette.measurementStroke,
|
||||
measurementLabelBackground: theme === 'dark' ? '#0f172a' : '#ffffff',
|
||||
measurementLabelText: theme === 'dark' ? '#e2e8f0' : '#171717',
|
||||
measurementLabelBackground: isDark ? '#0f172a' : '#ffffff',
|
||||
measurementLabelText: isDark ? '#e2e8f0' : '#171717',
|
||||
}),
|
||||
[palette, theme],
|
||||
[palette, isDark],
|
||||
)
|
||||
const slabSelectionHatchId = useMemo(() => `floorplan-slab-selection-hatch-${theme}`, [theme])
|
||||
const slabSelectionHatchId = useMemo(() => `floorplan-slab-selection-hatch-${isDark}`, [isDark])
|
||||
const gridSteps = useMemo(
|
||||
() => getVisibleGridSteps(viewBox.width, surfaceSize.width),
|
||||
[surfaceSize.width, viewBox.width],
|
||||
@@ -7620,7 +7620,7 @@ export function FloorplanPanel() {
|
||||
|
||||
document.body.style.userSelect = 'none'
|
||||
document.body.style.cursor = shouldRotate
|
||||
? getGuideRotateCursor(theme === 'dark')
|
||||
? getGuideRotateCursor(isDark)
|
||||
: getGuideResizeCursor(corner, rotationSvg)
|
||||
|
||||
const nextDraft: GuideTransformDraft = {
|
||||
@@ -7633,7 +7633,7 @@ export function FloorplanPanel() {
|
||||
guideTransformDraftRef.current = nextDraft
|
||||
setGuideTransformDraft(nextDraft)
|
||||
},
|
||||
[canInteractWithGuides, guideUi, handleGuideSelect, theme],
|
||||
[canInteractWithGuides, guideUi, handleGuideSelect, isDark],
|
||||
)
|
||||
const handleGuideTranslateStart = useCallback(
|
||||
(guide: GuideNode, event: ReactPointerEvent<SVGRectElement>) => {
|
||||
@@ -8237,7 +8237,7 @@ export function FloorplanPanel() {
|
||||
{showGuides && canInteractWithGuides && selectedGuide && (
|
||||
<FloorplanGuideHandleHint
|
||||
anchor={guideHandleHintAnchor}
|
||||
isDarkMode={theme === 'dark'}
|
||||
isDarkMode={isDark}
|
||||
isMacPlatform={isMacPlatform}
|
||||
rotationModifierPressed={rotationModifierPressed}
|
||||
/>
|
||||
@@ -8605,7 +8605,7 @@ export function FloorplanPanel() {
|
||||
{selectedGuide && showGuides && (
|
||||
<FloorplanGuideSelectionOverlay
|
||||
guide={selectedGuide}
|
||||
isDarkMode={theme === 'dark'}
|
||||
isDarkMode={isDark}
|
||||
onCornerHoverChange={setHoveredGuideCorner}
|
||||
onCornerPointerDown={handleGuideCornerPointerDown}
|
||||
rotationModifierPressed={rotationModifierPressed}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { GRID_LAYER, getSceneTheme, useViewer } from '@pascal-app/viewer'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { MathUtils, type Mesh, Vector2 } from 'three'
|
||||
import { color, float, fract, fwidth, mix, positionLocal, uniform } from 'three/tsl'
|
||||
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { useGridEvents } from '../../hooks/use-grid-events'
|
||||
import { EDITOR_LAYER } from '../../lib/constants'
|
||||
|
||||
export const Grid = ({
|
||||
cellSize = 0.5,
|
||||
@@ -31,11 +30,11 @@ export const Grid = ({
|
||||
fadeStrength?: number
|
||||
revealRadius?: number
|
||||
}) => {
|
||||
const theme = useViewer((state) => state.theme)
|
||||
const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
|
||||
|
||||
// Use slightly lighter colors for dark mode grid to make it apparent
|
||||
const effectiveCellColor = theme === 'dark' ? '#555566' : cellColor
|
||||
const effectiveSectionColor = theme === 'dark' ? '#666677' : sectionColor
|
||||
// Use slightly lighter colors for dark themes' grid to make it apparent
|
||||
const effectiveCellColor = isDark ? '#555566' : cellColor
|
||||
const effectiveSectionColor = isDark ? '#666677' : sectionColor
|
||||
|
||||
const cursorPositionRef = useRef(new Vector2(0, 0))
|
||||
|
||||
@@ -149,7 +148,7 @@ export const Grid = ({
|
||||
|
||||
return (
|
||||
<mesh
|
||||
layers={EDITOR_LAYER}
|
||||
layers={GRID_LAYER}
|
||||
material={material}
|
||||
ref={gridRef}
|
||||
rotation-x={-Math.PI / 2}
|
||||
|
||||
@@ -88,6 +88,7 @@ const EDITOR_HOVER_STYLES: HoverStyles = {
|
||||
pulse: false,
|
||||
},
|
||||
}
|
||||
const EDITOR_DEFAULT_RENDER = { shading: 'solid' } as const
|
||||
|
||||
/**
|
||||
* Wire up module-level singletons (spatial grid, space detection, SFX) for
|
||||
@@ -900,7 +901,9 @@ const ViewerCanvas = memo(function ViewerCanvas({
|
||||
) : null}
|
||||
<SelectionPersistenceManager enabled={hasLoadedInitialScene && !showLoader} />
|
||||
<Viewer
|
||||
defaultRender={EDITOR_DEFAULT_RENDER}
|
||||
hoverStyles={EDITOR_HOVER_STYLES}
|
||||
renderContext="editor"
|
||||
selectionManager={isFirstPersonMode ? 'default' : 'custom'}
|
||||
>
|
||||
<ViewerSceneContent
|
||||
@@ -1072,7 +1075,12 @@ export default function Editor({
|
||||
}, [isFirstPersonMode])
|
||||
|
||||
const previewViewerContent = (
|
||||
<Viewer hoverStyles={EDITOR_HOVER_STYLES} selectionManager="default">
|
||||
<Viewer
|
||||
defaultRender={EDITOR_DEFAULT_RENDER}
|
||||
hoverStyles={EDITOR_HOVER_STYLES}
|
||||
renderContext="editor"
|
||||
selectionManager="default"
|
||||
>
|
||||
<ExportManager />
|
||||
<ViewerZoneSystem />
|
||||
<CeilingSystem />
|
||||
|
||||
@@ -15,13 +15,13 @@ import {
|
||||
isRegistrySelectable,
|
||||
type NodeEvent,
|
||||
nodeRegistry,
|
||||
type RidgeVentNode,
|
||||
type RoofEvent,
|
||||
type RoofNode,
|
||||
type RoofSegmentEvent,
|
||||
type RoofSegmentNode,
|
||||
resolveLevelId,
|
||||
resolveMaterial,
|
||||
type RidgeVentNode,
|
||||
type ShelfNode,
|
||||
type SlabNode,
|
||||
type StairEvent,
|
||||
@@ -30,9 +30,6 @@ import {
|
||||
type StairSurfaceMaterialRole,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
type WallEvent,
|
||||
type WallNode,
|
||||
type WallSurfaceSide,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
import {
|
||||
@@ -42,7 +39,6 @@ import {
|
||||
getRoofMaterialArray,
|
||||
getStairBodyMaterials,
|
||||
getStairRailingMaterial,
|
||||
getVisibleWallMaterials,
|
||||
useViewer,
|
||||
} from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
@@ -50,11 +46,10 @@ import { type BufferGeometry, Color, type Material, type Mesh, type Object3D } f
|
||||
import {
|
||||
type ActivePaintMaterial,
|
||||
buildRoofSegmentSurfaceMaterialPatch,
|
||||
buildRoofSurfaceMaterialUpdates,
|
||||
buildRoofSurfaceMaterialPatch,
|
||||
buildRoofSurfaceMaterialUpdates,
|
||||
buildSingleSurfaceMaterialPatch,
|
||||
buildStairSurfaceMaterialPatch,
|
||||
buildWallSurfaceMaterialPatch,
|
||||
hasActivePaintMaterial,
|
||||
resolveActivePaintMaterialFromSelection,
|
||||
} from '../../lib/material-paint'
|
||||
@@ -230,12 +225,14 @@ function previewCursor(cursor: string): PaintPreviewCleanup {
|
||||
}
|
||||
|
||||
function getSingleSurfacePreviewMaterial(material: ActivePaintMaterial): Material | null {
|
||||
const shading = useViewer.getState().shading
|
||||
|
||||
if (material.materialPreset) {
|
||||
return createMaterialFromPresetRef(material.materialPreset)
|
||||
return createMaterialFromPresetRef(material.materialPreset, shading)
|
||||
}
|
||||
|
||||
if (material.material) {
|
||||
return createMaterial(material.material)
|
||||
return createMaterial(material.material, shading)
|
||||
}
|
||||
|
||||
return null
|
||||
@@ -254,7 +251,13 @@ function applyRoofPaintPreview(
|
||||
...node,
|
||||
...buildRoofSurfaceMaterialPatch(node, role, material.material, material.materialPreset),
|
||||
}
|
||||
const previewMaterial = getRoofMaterialArray(previewNode)
|
||||
const previewMaterial = getRoofMaterialArray(
|
||||
previewNode,
|
||||
useViewer.getState().shading,
|
||||
useViewer.getState().textures,
|
||||
useViewer.getState().colorPreset,
|
||||
useViewer.getState().sceneTheme,
|
||||
)
|
||||
if (!previewMaterial) return null
|
||||
|
||||
return previewMeshMaterial(mesh, previewMaterial)
|
||||
@@ -275,12 +278,7 @@ function applyRoofSegmentPaintPreview(
|
||||
// material lands on the matching CSG groups.
|
||||
const previewNode: RoofSegmentNode = {
|
||||
...node,
|
||||
...buildRoofSegmentSurfaceMaterialPatch(
|
||||
node,
|
||||
role,
|
||||
material.material,
|
||||
material.materialPreset,
|
||||
),
|
||||
...buildRoofSegmentSurfaceMaterialPatch(node, role, material.material, material.materialPreset),
|
||||
}
|
||||
const resolveSlot = (r: 'top' | 'edge' | 'wall'): Material | null => {
|
||||
const parentSpec = parent ? getEffectiveRoofSurfaceMaterial(parent, r) : undefined
|
||||
@@ -320,8 +318,9 @@ function applyStairPaintPreview(
|
||||
...node,
|
||||
...buildStairSurfaceMaterialPatch(node, role, material.material, material.materialPreset),
|
||||
}
|
||||
const bodyMaterials = getStairBodyMaterials(previewNode)
|
||||
const railingMaterial = getStairRailingMaterial(previewNode)
|
||||
const shading = useViewer.getState().shading
|
||||
const bodyMaterials = getStairBodyMaterials(previewNode, shading)
|
||||
const railingMaterial = getStairRailingMaterial(previewNode, shading)
|
||||
const restores: PaintPreviewCleanup[] = []
|
||||
|
||||
root.traverse((object) => {
|
||||
@@ -354,14 +353,7 @@ function applyStairPaintPreview(
|
||||
}
|
||||
|
||||
function applySingleSurfacePaintPreview(
|
||||
node:
|
||||
| FenceNode
|
||||
| ColumnNode
|
||||
| SlabNode
|
||||
| CeilingNode
|
||||
| ShelfNode
|
||||
| BoxVentNode
|
||||
| RidgeVentNode,
|
||||
node: FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode | BoxVentNode | RidgeVentNode,
|
||||
material: ActivePaintMaterial,
|
||||
): PaintPreviewCleanup | null {
|
||||
if (node.type === 'ceiling') {
|
||||
@@ -881,17 +873,15 @@ export const SelectionManager = () => {
|
||||
apply:
|
||||
compatible && role
|
||||
? () => {
|
||||
useScene
|
||||
.getState()
|
||||
.updateNode(
|
||||
node.id as AnyNodeId,
|
||||
paintCap.buildPatch({
|
||||
node,
|
||||
role,
|
||||
material: activePaintMaterial.material,
|
||||
materialPreset: activePaintMaterial.materialPreset,
|
||||
}) as Partial<AnyNode>,
|
||||
)
|
||||
useScene.getState().updateNode(
|
||||
node.id as AnyNodeId,
|
||||
paintCap.buildPatch({
|
||||
node,
|
||||
role,
|
||||
material: activePaintMaterial.material,
|
||||
materialPreset: activePaintMaterial.materialPreset,
|
||||
}) as Partial<AnyNode>,
|
||||
)
|
||||
}
|
||||
: null,
|
||||
preview:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { SiteNode } from '@pascal-app/core'
|
||||
import { sceneRegistry, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { getSceneTheme, useViewer } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { createPortal, useFrame } from '@react-three/fiber'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
@@ -30,11 +30,10 @@ export function SiteEdgeLabels() {
|
||||
return node?.type === 'site' ? (node as SiteNode) : null
|
||||
})
|
||||
const unit = useViewer((state) => state.unit)
|
||||
const theme = useViewer((state) => state.theme)
|
||||
const isNight = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
|
||||
|
||||
const siteNodeId = siteNode?.id
|
||||
|
||||
const isNight = theme === 'dark'
|
||||
const color = isNight ? '#ffffff' : '#111111'
|
||||
const shadowColor = isNight ? '#111111' : '#ffffff'
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { emitter, sceneRegistry } from '@pascal-app/core'
|
||||
import { SSGI_PARAMS, snapLevelsToTruePositions, useViewer } from '@pascal-app/viewer'
|
||||
import { GRID_LAYER, SSGI_PARAMS, snapLevelsToTruePositions, useViewer } from '@pascal-app/viewer'
|
||||
import type { CameraControls } from '@react-three/drei'
|
||||
import { useThree } from '@react-three/fiber'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
@@ -62,6 +62,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
useEffect(() => {
|
||||
const cam = new THREE.PerspectiveCamera(60, THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT, 0.1, 1000)
|
||||
cam.layers.disable(EDITOR_LAYER)
|
||||
cam.layers.disable(GRID_LAYER)
|
||||
thumbnailCameraRef.current = cam
|
||||
|
||||
let mounted = true
|
||||
@@ -73,7 +74,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
|
||||
// pass() handles MRT internally for all material types, including custom
|
||||
// shaders — unlike renderer.setMRT() which crashes on non-NodeMaterials.
|
||||
// pass() also respects camera.layers, so EDITOR_LAYER objects are filtered.
|
||||
// pass() also respects camera.layers, so EDITOR_LAYER + GRID_LAYER objects are filtered.
|
||||
const scenePass = pass(scene, cam)
|
||||
scenePass.setMRT(
|
||||
mrt({
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
type WallMiterData,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { getSceneTheme, useViewer } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { createPortal, useFrame } from '@react-three/fiber'
|
||||
import { useMemo, useState } from 'react'
|
||||
@@ -515,9 +515,8 @@ function SelectedMeasurementAnnotation({ node }: { node: WallNode | ItemNode })
|
||||
|
||||
function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
|
||||
const nodes = useScene((state) => state.nodes)
|
||||
const theme = useViewer((state) => state.theme)
|
||||
const unit = useViewer((state) => state.unit)
|
||||
const isNight = theme === 'dark'
|
||||
const isNight = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
|
||||
const color = isNight ? '#ffffff' : '#111111'
|
||||
const shadowColor = isNight ? '#111111' : '#ffffff'
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
type Object3D,
|
||||
OrthographicCamera,
|
||||
} from 'three'
|
||||
import { EDITOR_LAYER } from '../../lib/constants'
|
||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||
import useEditor from '../../store/use-editor'
|
||||
|
||||
@@ -198,6 +199,7 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov
|
||||
<group position={handle.position} rotation={[0, handle.rotationY, 0]} scale={scale}>
|
||||
<mesh
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
onPointerDown={activateWallMove}
|
||||
onPointerEnter={(event) => {
|
||||
event.stopPropagation()
|
||||
@@ -263,6 +265,7 @@ function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: Wal
|
||||
<group position={handle.position} rotation={[0, handle.rotationY, 0]} scale={scale}>
|
||||
<mesh
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
onPointerDown={activateFenceMove}
|
||||
onPointerEnter={(event) => {
|
||||
event.stopPropagation()
|
||||
|
||||
@@ -22,7 +22,6 @@ import { Euler, Matrix3, Quaternion, Vector3 } from 'three'
|
||||
import {
|
||||
calculateCursorRotation,
|
||||
calculateItemRotation,
|
||||
calculateRoofRotation,
|
||||
getGridAlignedDimensions,
|
||||
getSideFromNormal,
|
||||
isValidWallSideFace,
|
||||
|
||||
@@ -526,14 +526,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
cursorGroupRef.current.rotation.set(0, result.cursorRotationY, 0)
|
||||
}
|
||||
|
||||
const initRotation: [number, number, number] = result.cursorRotation ?? [0, result.cursorRotationY, 0]
|
||||
const initRotation: [number, number, number] = result.cursorRotation ?? [
|
||||
0,
|
||||
result.cursorRotationY,
|
||||
0,
|
||||
]
|
||||
|
||||
draftNode.create(
|
||||
gridPosition.current,
|
||||
asset,
|
||||
initRotation,
|
||||
configRef.current.defaultScale,
|
||||
)
|
||||
draftNode.create(gridPosition.current, asset, initRotation, configRef.current.defaultScale)
|
||||
|
||||
const draft = draftNode.current
|
||||
if (draft) {
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
PencilLine,
|
||||
Plus,
|
||||
Redo2,
|
||||
Sparkles,
|
||||
Square,
|
||||
SquareStack,
|
||||
Sun,
|
||||
@@ -281,19 +282,20 @@ export function EditorCommands() {
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'editor.viewer.theme',
|
||||
label: () => {
|
||||
const theme = useViewer.getState().theme
|
||||
return theme === 'dark' ? 'Switch to Light Theme' : 'Switch to Dark Theme'
|
||||
},
|
||||
id: 'editor.viewer.shading-solid',
|
||||
label: 'Switch to Solid',
|
||||
group: 'Viewer Controls',
|
||||
icon: <Sun className="h-4 w-4" />, // icon is static; label conveys the action
|
||||
keywords: ['theme', 'dark', 'light', 'appearance', 'color'],
|
||||
execute: () =>
|
||||
run(() => {
|
||||
const { theme, setTheme } = useViewer.getState()
|
||||
setTheme(theme === 'dark' ? 'light' : 'dark')
|
||||
}),
|
||||
icon: <Box className="h-4 w-4" />,
|
||||
keywords: ['solid', 'shading', 'render', 'mode', 'performance'],
|
||||
execute: () => run(() => useViewer.getState().setShading('solid')),
|
||||
},
|
||||
{
|
||||
id: 'editor.viewer.shading-rendered',
|
||||
label: 'Switch to Rendered',
|
||||
group: 'Viewer Controls',
|
||||
icon: <Sparkles className="h-4 w-4" />,
|
||||
keywords: ['rendered', 'shading', 'render', 'mode', 'quality'],
|
||||
execute: () => run(() => useViewer.getState().setShading('rendered')),
|
||||
},
|
||||
{
|
||||
id: 'editor.viewer.camera-snapshot',
|
||||
|
||||
@@ -184,6 +184,7 @@ export function SettingsPanel({
|
||||
const resetSelection = useViewer((state) => state.resetSelection)
|
||||
const exportScene = useViewer((state) => state.exportScene)
|
||||
const showGrid = useViewer((state) => state.showGrid)
|
||||
const shadows = useViewer((state) => state.shadows)
|
||||
const setPhase = useEditor((state) => state.setPhase)
|
||||
const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(false)
|
||||
const [pendingImport, setPendingImport] = useState<PendingImport | null>(null)
|
||||
@@ -340,6 +341,16 @@ export function SettingsPanel({
|
||||
onCheckedChange={(checked) => useViewer.getState().setShowGrid(checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-sm">Shadows</div>
|
||||
<div className="text-muted-foreground text-xs">Cast shadows from lights</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={shadows}
|
||||
onCheckedChange={(checked) => useViewer.getState().setShadows(checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -10,13 +10,34 @@ import {
|
||||
useScene,
|
||||
type ZoneNode,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { ArrowLeft, Camera, ChevronRight, Diamond, Layers, Moon, Sun } from 'lucide-react'
|
||||
import { motion } from 'motion/react'
|
||||
import {
|
||||
CLAY_PALETTE,
|
||||
type EdgeMode,
|
||||
getSceneTheme,
|
||||
SCENE_THEMES,
|
||||
useViewer,
|
||||
} from '@pascal-app/viewer'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Box,
|
||||
Camera,
|
||||
Check,
|
||||
ChevronRight,
|
||||
Diamond,
|
||||
Layers,
|
||||
PenLine,
|
||||
Sparkles,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { cn } from '../lib/utils'
|
||||
import { ActionButton } from './ui/action-menu/action-button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from './ui/primitives/dropdown-menu'
|
||||
import { TooltipProvider } from './ui/primitives/tooltip'
|
||||
|
||||
type ProjectOwner = {
|
||||
@@ -60,6 +81,143 @@ const wallModeConfig = {
|
||||
},
|
||||
}
|
||||
|
||||
const SHADING_OPTIONS = [
|
||||
{ id: 'solid', name: 'Solid', detail: 'Flat and fast — no ambient occlusion', icon: Box },
|
||||
{ id: 'rendered', name: 'Rendered', detail: 'Full ambient occlusion', icon: Sparkles },
|
||||
] as const
|
||||
|
||||
function RenderModeMenu() {
|
||||
const shading = useViewer((s) => s.shading)
|
||||
const active = SHADING_OPTIONS.find((o) => o.id === shading) ?? SHADING_OPTIONS[0]
|
||||
const ActiveIcon = active.icon
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<ActionButton
|
||||
className="text-muted-foreground/80 hover:bg-white/5 hover:text-foreground"
|
||||
label={`Render: ${active.name}`}
|
||||
size="icon"
|
||||
tooltipSide="top"
|
||||
variant="ghost"
|
||||
>
|
||||
<ActiveIcon className="h-6 w-6" />
|
||||
</ActionButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="min-w-56" side="top">
|
||||
{SHADING_OPTIONS.map((option) => {
|
||||
const OptionIcon = option.icon
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={option.id}
|
||||
onSelect={() => useViewer.getState().setShading(option.id)}
|
||||
>
|
||||
<OptionIcon />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-foreground">{option.name}</span>
|
||||
<span className="text-muted-foreground text-xs">{option.detail}</span>
|
||||
</div>
|
||||
{shading === option.id ? <Check className="ml-auto text-foreground" /> : null}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
function SceneThemeMenu() {
|
||||
const sceneTheme = useViewer((s) => s.sceneTheme)
|
||||
const active = getSceneTheme(sceneTheme)
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<ActionButton
|
||||
className="text-muted-foreground/80 hover:bg-white/5 hover:text-foreground"
|
||||
label={`Theme: ${active.name}`}
|
||||
size="icon"
|
||||
tooltipSide="top"
|
||||
variant="ghost"
|
||||
>
|
||||
<Icon color="currentColor" height={24} icon="lucide:swatch-book" width={24} />
|
||||
</ActionButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="min-w-48" side="top">
|
||||
{SCENE_THEMES.map((sceneThemeOption) => {
|
||||
const swatches = (['wall', 'roof', 'floor', 'glazing'] as const).map(
|
||||
(role) => sceneThemeOption.clayTints?.[role] ?? CLAY_PALETTE[role],
|
||||
)
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={sceneThemeOption.id}
|
||||
onSelect={() => useViewer.getState().setSceneTheme(sceneThemeOption.id)}
|
||||
>
|
||||
<span
|
||||
className="grid h-5 w-5 shrink-0 grid-cols-2 overflow-hidden rounded-sm border border-black/10"
|
||||
style={{ backgroundColor: sceneThemeOption.background }}
|
||||
>
|
||||
{swatches.map((color, index) => (
|
||||
<span
|
||||
key={`${sceneThemeOption.id}-${index}`}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
<span className="text-foreground">{sceneThemeOption.name}</span>
|
||||
{sceneTheme === sceneThemeOption.id ? (
|
||||
<Check className="ml-auto text-foreground" />
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
const EDGE_OPTIONS = [
|
||||
{ id: 'off', name: 'Off', detail: 'No edge lines' },
|
||||
{ id: 'soft', name: 'Soft', detail: 'Faint outline of major creases' },
|
||||
{ id: 'strong', name: 'Strong', detail: 'Crisp, opaque edge lines' },
|
||||
] as const satisfies readonly { id: EdgeMode; name: string; detail: string }[]
|
||||
|
||||
function EdgesMenu() {
|
||||
const edges = useViewer((s) => s.edges)
|
||||
const active = EDGE_OPTIONS.find((o) => o.id === edges) ?? EDGE_OPTIONS[0]
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<ActionButton
|
||||
className={
|
||||
edges === 'off'
|
||||
? 'text-muted-foreground/80 hover:bg-white/5 hover:text-foreground'
|
||||
: 'bg-white/10 text-foreground'
|
||||
}
|
||||
label={`Edges: ${active.name}`}
|
||||
size="icon"
|
||||
tooltipSide="top"
|
||||
variant="ghost"
|
||||
>
|
||||
<PenLine className="h-6 w-6" />
|
||||
</ActionButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="min-w-56" side="top">
|
||||
{EDGE_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.id}
|
||||
onSelect={() => useViewer.getState().setEdges(option.id)}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-foreground">{option.name}</span>
|
||||
<span className="text-muted-foreground text-xs">{option.detail}</span>
|
||||
</div>
|
||||
{edges === option.id ? <Check className="ml-auto text-foreground" /> : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
const getNodeName = (node: AnyNode): string => {
|
||||
if ('name' in node && node.name) return node.name
|
||||
if (node.type === 'wall') return 'Wall'
|
||||
@@ -93,7 +251,6 @@ export const ViewerOverlay = ({
|
||||
const cameraMode = useViewer((s) => s.cameraMode)
|
||||
const levelMode = useViewer((s) => s.levelMode)
|
||||
const wallMode = useViewer((s) => s.wallMode)
|
||||
const theme = useViewer((s) => s.theme)
|
||||
|
||||
// Subscribe only to the specific nodes we read so that creating an unrelated
|
||||
// node elsewhere in the scene doesn't re-render this overlay.
|
||||
@@ -280,53 +437,6 @@ export const ViewerOverlay = ({
|
||||
<div className="dark absolute bottom-6 left-1/2 z-20 -translate-x-1/2 text-foreground">
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div className="pointer-events-auto flex h-14 flex-row items-center justify-center gap-1.5 rounded-2xl border border-border/40 bg-background/95 p-1.5 shadow-lg backdrop-blur-xl transition-colors duration-200 ease-out">
|
||||
{/* Theme Toggle */}
|
||||
<button
|
||||
aria-label="Toggle theme"
|
||||
className="flex h-[36px] shrink-0 cursor-pointer items-center rounded-full border border-border/50 bg-accent/50 p-1"
|
||||
onClick={() => useViewer.getState().setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||
type="button"
|
||||
>
|
||||
<div className="relative flex">
|
||||
{/* Sliding Background */}
|
||||
<motion.div
|
||||
animate={{
|
||||
x: theme === 'light' ? '100%' : '0%',
|
||||
}}
|
||||
className="absolute inset-0 rounded-full bg-white shadow-sm dark:bg-white/20"
|
||||
initial={false}
|
||||
style={{ width: '50%' }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 500,
|
||||
damping: 35,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Dark Mode Icon */}
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none relative z-10 flex h-7 w-9 items-center justify-center rounded-full transition-colors duration-200',
|
||||
theme === 'dark' ? 'text-foreground' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<Moon className="h-4 w-4" />
|
||||
</div>
|
||||
|
||||
{/* Light Mode Icon */}
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none relative z-10 flex h-7 w-9 items-center justify-center rounded-full transition-colors duration-200',
|
||||
theme === 'light' ? 'text-foreground' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<Sun className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="mx-1 h-5 w-px bg-border/40" />
|
||||
|
||||
{/* Scans and Guides Visibility */}
|
||||
{canShowScans && (
|
||||
<ActionButton
|
||||
@@ -392,6 +502,12 @@ export const ViewerOverlay = ({
|
||||
<Camera className="h-6 w-6" />
|
||||
</ActionButton>
|
||||
|
||||
<RenderModeMenu />
|
||||
|
||||
<SceneThemeMenu />
|
||||
|
||||
<EdgesMenu />
|
||||
|
||||
{/* Level Mode */}
|
||||
<ActionButton
|
||||
className={cn(
|
||||
|
||||
@@ -110,6 +110,12 @@ export { PanelWrapper } from './components/ui/panels/panel-wrapper'
|
||||
// hardware / type / opening presets.
|
||||
export { PresetsPopover } from './components/ui/panels/presets/presets-popover'
|
||||
export { PALETTE_COLORS } from './components/ui/primitives/color-dot'
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from './components/ui/primitives/dropdown-menu'
|
||||
export { useSidebarStore } from './components/ui/primitives/sidebar'
|
||||
export { Slider } from './components/ui/primitives/slider'
|
||||
export { SceneLoader } from './components/ui/scene-loader'
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { OVERLAY_LAYER } from '@pascal-app/viewer'
|
||||
|
||||
/** Three.js layer used for editor-only objects (helpers, grid, polygon editors).
|
||||
* The thumbnail camera renders only layer 0, so these are excluded from thumbnails. */
|
||||
export const EDITOR_LAYER = 1
|
||||
* The thumbnail camera renders only layer 0, so these are excluded from thumbnails.
|
||||
* Aliased to viewer's `OVERLAY_LAYER` so the post-processing overlay pass and the
|
||||
* editor's overlay meshes stay on the same layer. */
|
||||
export const EDITOR_LAYER = OVERLAY_LAYER
|
||||
|
||||
@@ -8,12 +8,11 @@ import {
|
||||
type ChimneyMaterialRole,
|
||||
type ChimneyNode,
|
||||
type ColumnNode,
|
||||
type DormerNode,
|
||||
type DormerSurfaceMaterialRole,
|
||||
type FenceNode,
|
||||
getCatalogMaterialById,
|
||||
getEffectiveDormerSurfaceMaterial,
|
||||
getEffectiveRoofSurfaceMaterial,
|
||||
getEffectiveSegmentSurfaceMaterial,
|
||||
getEffectiveStairSurfaceMaterial,
|
||||
getEffectiveWallSurfaceMaterial,
|
||||
getLibraryMaterialIdFromRef,
|
||||
@@ -21,7 +20,6 @@ import {
|
||||
type MaterialTarget,
|
||||
nodeRegistry,
|
||||
type RidgeVentNode,
|
||||
getEffectiveSegmentSurfaceMaterial,
|
||||
type RoofNode,
|
||||
type RoofSegmentNode,
|
||||
type RoofSegmentSurfaceMaterialRole,
|
||||
@@ -143,17 +141,11 @@ export function buildRoofSegmentSurfaceMaterialPatch(
|
||||
): Partial<RoofSegmentNode> {
|
||||
const nextSurfaceMaterial = { material, materialPreset }
|
||||
const nextTop =
|
||||
targetRole === 'top'
|
||||
? nextSurfaceMaterial
|
||||
: getEffectiveSegmentSurfaceMaterial(node, 'top')
|
||||
targetRole === 'top' ? nextSurfaceMaterial : getEffectiveSegmentSurfaceMaterial(node, 'top')
|
||||
const nextEdge =
|
||||
targetRole === 'edge'
|
||||
? nextSurfaceMaterial
|
||||
: getEffectiveSegmentSurfaceMaterial(node, 'edge')
|
||||
targetRole === 'edge' ? nextSurfaceMaterial : getEffectiveSegmentSurfaceMaterial(node, 'edge')
|
||||
const nextWall =
|
||||
targetRole === 'wall'
|
||||
? nextSurfaceMaterial
|
||||
: getEffectiveSegmentSurfaceMaterial(node, 'wall')
|
||||
targetRole === 'wall' ? nextSurfaceMaterial : getEffectiveSegmentSurfaceMaterial(node, 'wall')
|
||||
|
||||
return {
|
||||
topMaterial: nextTop.material,
|
||||
@@ -177,9 +169,12 @@ export function buildRoofSurfaceMaterialUpdates(
|
||||
const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = [
|
||||
{
|
||||
id: node.id as AnyNodeId,
|
||||
data: buildRoofSurfaceMaterialPatch(node, targetRole, material, materialPreset) as Partial<
|
||||
AnyNode
|
||||
>,
|
||||
data: buildRoofSurfaceMaterialPatch(
|
||||
node,
|
||||
targetRole,
|
||||
material,
|
||||
materialPreset,
|
||||
) as Partial<AnyNode>,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -27,9 +27,7 @@ describe('buildBoxVentGeometry', () => {
|
||||
// quads, but the bottom + top fan triangulations always include
|
||||
// every profile edge (including the degenerate ones — they're
|
||||
// zero-area triangles that survive the buffer).
|
||||
const box = buildBoxVentGeometry(
|
||||
BoxVentNode.parse({ style: 'box', cornerBevel: 0 }),
|
||||
)
|
||||
const box = buildBoxVentGeometry(BoxVentNode.parse({ style: 'box', cornerBevel: 0 }))
|
||||
expect(box.getAttribute('position').count).toBeGreaterThan(0)
|
||||
// Confirm the position attribute carries finite values only.
|
||||
const positions = box.getAttribute('position').array as Float32Array
|
||||
@@ -47,9 +45,7 @@ describe('buildBoxVentGeometry', () => {
|
||||
})
|
||||
|
||||
test('cap style: zero overhang drops the flange quad', () => {
|
||||
const noFlange = buildBoxVentGeometry(
|
||||
BoxVentNode.parse({ style: 'cap', hoodOverhang: 0 }),
|
||||
)
|
||||
const noFlange = buildBoxVentGeometry(BoxVentNode.parse({ style: 'cap', hoodOverhang: 0 }))
|
||||
// 10 quads × 6 vertices/quad = 60.
|
||||
expect(noFlange.getAttribute('position').count).toBe(60)
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type NodeDefinition, BoxVentNode as BoxVentNodeSchema } from '@pascal-app/core'
|
||||
import { BoxVentNode as BoxVentNodeSchema, type NodeDefinition } from '@pascal-app/core'
|
||||
import { boxVentParametrics } from './parametrics'
|
||||
import { BoxVentNode } from './schema'
|
||||
|
||||
@@ -33,6 +33,7 @@ export const boxVentDefinition: NodeDefinition<typeof BoxVentNode> = {
|
||||
schemaVersion: 1,
|
||||
schema: BoxVentNode,
|
||||
category: 'structure',
|
||||
surfaceRole: 'roof',
|
||||
|
||||
defaults: () => {
|
||||
const stub = BoxVentNodeSchema.parse({ id: 'bvent_default' as never, type: 'box-vent' })
|
||||
|
||||
@@ -55,10 +55,7 @@ function buildBoxShape(node: BoxVentNode): THREE.BufferGeometry {
|
||||
const w = node.width
|
||||
const d = node.depth
|
||||
const h = node.height
|
||||
const baseInset = Math.max(
|
||||
0,
|
||||
Math.min(node.baseInset ?? 0.06, Math.min(w, d) / 2 - 0.005),
|
||||
)
|
||||
const baseInset = Math.max(0, Math.min(node.baseInset ?? 0.06, Math.min(w, d) / 2 - 0.005))
|
||||
const baseH = Math.max(0.005, Math.min(node.baseHeight ?? 0.04, h - 0.005))
|
||||
const baseW = Math.max(0.01, w - 2 * baseInset)
|
||||
const baseD = Math.max(0.01, d - 2 * baseInset)
|
||||
@@ -73,16 +70,10 @@ function buildBoxShape(node: BoxVentNode): THREE.BufferGeometry {
|
||||
|
||||
// Lower (smaller) riser. Top is hidden under the cover but include
|
||||
// it anyway — overlap is invisible and the geometry stays simple.
|
||||
buildRoundedExtrusion(
|
||||
positions, normals, uvs,
|
||||
baseW, baseD, 0, baseH, cornerBevel,
|
||||
)
|
||||
buildRoundedExtrusion(positions, normals, uvs, baseW, baseD, 0, baseH, cornerBevel)
|
||||
// Upper (larger) cover. Bottom partially shows where it overhangs the
|
||||
// riser, so it's always rendered.
|
||||
buildRoundedExtrusion(
|
||||
positions, normals, uvs,
|
||||
w, d, baseH, h, cornerBevel,
|
||||
)
|
||||
buildRoundedExtrusion(positions, normals, uvs, w, d, baseH, h, cornerBevel)
|
||||
|
||||
return buildBufferGeometry(positions, normals, uvs)
|
||||
}
|
||||
@@ -113,10 +104,16 @@ function buildRoundedExtrusion(
|
||||
if (len < 1e-9) continue // degenerate edge (zero-bevel duplicate corner points)
|
||||
const nx = ez / len
|
||||
const nz = -ex / len
|
||||
pushQuad(positions, normals, uvs,
|
||||
[a.x, y0, a.z], [b.x, y0, b.z],
|
||||
[b.x, y1, b.z], [a.x, y1, a.z],
|
||||
[nx, 0, nz])
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[a.x, y0, a.z],
|
||||
[b.x, y0, b.z],
|
||||
[b.x, y1, b.z],
|
||||
[a.x, y1, a.z],
|
||||
[nx, 0, nz],
|
||||
)
|
||||
}
|
||||
|
||||
// Top cap (+Y normal): wind triangles CW from above so the cross
|
||||
@@ -124,18 +121,14 @@ function buildRoundedExtrusion(
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = profile[i]!
|
||||
const b = profile[(i + 1) % n]!
|
||||
pushTri(positions, normals, uvs,
|
||||
[0, y1, 0], [b.x, y1, b.z], [a.x, y1, a.z],
|
||||
[0, 1, 0])
|
||||
pushTri(positions, normals, uvs, [0, y1, 0], [b.x, y1, b.z], [a.x, y1, a.z], [0, 1, 0])
|
||||
}
|
||||
|
||||
// Bottom cap (-Y normal): wind CCW from above.
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = profile[i]!
|
||||
const b = profile[(i + 1) % n]!
|
||||
pushTri(positions, normals, uvs,
|
||||
[0, y0, 0], [a.x, y0, a.z], [b.x, y0, b.z],
|
||||
[0, -1, 0])
|
||||
pushTri(positions, normals, uvs, [0, y0, 0], [a.x, y0, a.z], [b.x, y0, b.z], [0, -1, 0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,9 +146,9 @@ function roundedRectProfile(
|
||||
const r = Math.max(0, Math.min(bevel, hw, hd))
|
||||
// 4 corner centers, CCW from +X+Z (NE, NW, SW, SE).
|
||||
const corners: Array<{ cx: number; cz: number; startAngle: number }> = [
|
||||
{ cx: hw - r, cz: hd - r, startAngle: 0 }, // NE
|
||||
{ cx: -(hw - r), cz: hd - r, startAngle: Math.PI / 2 }, // NW
|
||||
{ cx: -(hw - r), cz: -(hd - r), startAngle: Math.PI }, // SW
|
||||
{ cx: hw - r, cz: hd - r, startAngle: 0 }, // NE
|
||||
{ cx: -(hw - r), cz: hd - r, startAngle: Math.PI / 2 }, // NW
|
||||
{ cx: -(hw - r), cz: -(hd - r), startAngle: Math.PI }, // SW
|
||||
{ cx: hw - r, cz: -(hd - r), startAngle: Math.PI * 1.5 }, // SE
|
||||
]
|
||||
const out: Array<{ x: number; z: number }> = []
|
||||
@@ -231,27 +224,83 @@ function buildCapShape(node: BoxVentNode): THREE.BufferGeometry {
|
||||
const uvs: number[] = []
|
||||
|
||||
// ── Body (4 walls + sealed bottom)
|
||||
pushQuad(positions, normals, uvs,
|
||||
[hw, 0, -hd], [hw, 0, hd], [hw, bodyH, hd], [hw, bodyH, -hd], [1, 0, 0])
|
||||
pushQuad(positions, normals, uvs,
|
||||
[-hw, 0, hd], [-hw, 0, -hd], [-hw, bodyH, -hd], [-hw, bodyH, hd], [-1, 0, 0])
|
||||
pushQuad(positions, normals, uvs,
|
||||
[hw, 0, hd], [-hw, 0, hd], [-hw, bodyH, hd], [hw, bodyH, hd], [0, 0, 1])
|
||||
pushQuad(positions, normals, uvs,
|
||||
[-hw, 0, -hd], [hw, 0, -hd], [hw, bodyH, -hd], [-hw, bodyH, -hd], [0, 0, -1])
|
||||
pushQuad(positions, normals, uvs,
|
||||
[-hw, 0, -hd], [-hw, 0, hd], [hw, 0, hd], [hw, 0, -hd], [0, -1, 0])
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[hw, 0, -hd],
|
||||
[hw, 0, hd],
|
||||
[hw, bodyH, hd],
|
||||
[hw, bodyH, -hd],
|
||||
[1, 0, 0],
|
||||
)
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[-hw, 0, hd],
|
||||
[-hw, 0, -hd],
|
||||
[-hw, bodyH, -hd],
|
||||
[-hw, bodyH, hd],
|
||||
[-1, 0, 0],
|
||||
)
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[hw, 0, hd],
|
||||
[-hw, 0, hd],
|
||||
[-hw, bodyH, hd],
|
||||
[hw, bodyH, hd],
|
||||
[0, 0, 1],
|
||||
)
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[-hw, 0, -hd],
|
||||
[hw, 0, -hd],
|
||||
[hw, bodyH, -hd],
|
||||
[-hw, bodyH, -hd],
|
||||
[0, 0, -1],
|
||||
)
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[-hw, 0, -hd],
|
||||
[-hw, 0, hd],
|
||||
[hw, 0, hd],
|
||||
[hw, 0, -hd],
|
||||
[0, -1, 0],
|
||||
)
|
||||
|
||||
// ── Body top (only when there's a visible gap to look through)
|
||||
if (capGap > 0) {
|
||||
pushQuad(positions, normals, uvs,
|
||||
[-hw, bodyH, hd], [-hw, bodyH, -hd], [hw, bodyH, -hd], [hw, bodyH, hd], [0, 1, 0])
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[-hw, bodyH, hd],
|
||||
[-hw, bodyH, -hd],
|
||||
[hw, bodyH, -hd],
|
||||
[hw, bodyH, hd],
|
||||
[0, 1, 0],
|
||||
)
|
||||
}
|
||||
|
||||
// ── Flange underside (the bit of the cap base that overhangs the body)
|
||||
if (overhang > 0 || capGap > 0) {
|
||||
pushQuad(positions, normals, uvs,
|
||||
[-bw, y0, -bd], [-bw, y0, bd], [bw, y0, bd], [bw, y0, -bd], [0, -1, 0])
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[-bw, y0, -bd],
|
||||
[-bw, y0, bd],
|
||||
[bw, y0, bd],
|
||||
[bw, y0, -bd],
|
||||
[0, -1, 0],
|
||||
)
|
||||
}
|
||||
|
||||
// ── 4 chamfered cap faces (trapezoids: wider at base, narrow at top).
|
||||
@@ -260,23 +309,63 @@ function buildCapShape(node: BoxVentNode): THREE.BufferGeometry {
|
||||
const dx = bw - tw // horizontal slope run on the X-facing faces
|
||||
const dz = bd - td
|
||||
// +X face
|
||||
pushQuad(positions, normals, uvs,
|
||||
[bw, y0, -bd], [bw, y0, bd], [tw, y1, td], [tw, y1, -td], [dx, capH, 0])
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[bw, y0, -bd],
|
||||
[bw, y0, bd],
|
||||
[tw, y1, td],
|
||||
[tw, y1, -td],
|
||||
[dx, capH, 0],
|
||||
)
|
||||
// -X face
|
||||
pushQuad(positions, normals, uvs,
|
||||
[-bw, y0, bd], [-bw, y0, -bd], [-tw, y1, -td], [-tw, y1, td], [-dx, capH, 0])
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[-bw, y0, bd],
|
||||
[-bw, y0, -bd],
|
||||
[-tw, y1, -td],
|
||||
[-tw, y1, td],
|
||||
[-dx, capH, 0],
|
||||
)
|
||||
// +Z face
|
||||
pushQuad(positions, normals, uvs,
|
||||
[bw, y0, bd], [-bw, y0, bd], [-tw, y1, td], [tw, y1, td], [0, capH, dz])
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[bw, y0, bd],
|
||||
[-bw, y0, bd],
|
||||
[-tw, y1, td],
|
||||
[tw, y1, td],
|
||||
[0, capH, dz],
|
||||
)
|
||||
// -Z face
|
||||
pushQuad(positions, normals, uvs,
|
||||
[-bw, y0, -bd], [bw, y0, -bd], [tw, y1, -td], [-tw, y1, -td], [0, capH, -dz])
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[-bw, y0, -bd],
|
||||
[bw, y0, -bd],
|
||||
[tw, y1, -td],
|
||||
[-tw, y1, -td],
|
||||
[0, capH, -dz],
|
||||
)
|
||||
|
||||
// ── Flat closed top plane (no hollow opening — even if topTaper is 0,
|
||||
// this collapses to the original body cross-section; if topTaper is 1
|
||||
// it degenerates to a point and the four triangles meet, still closed).
|
||||
pushQuad(positions, normals, uvs,
|
||||
[-tw, y1, td], [-tw, y1, -td], [tw, y1, -td], [tw, y1, td], [0, 1, 0])
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[-tw, y1, td],
|
||||
[-tw, y1, -td],
|
||||
[tw, y1, -td],
|
||||
[tw, y1, td],
|
||||
[0, 1, 0],
|
||||
)
|
||||
|
||||
return buildBufferGeometry(positions, normals, uvs)
|
||||
}
|
||||
@@ -301,10 +390,12 @@ function buildDomeStyleShape(node: BoxVentNode): THREE.BufferGeometry {
|
||||
const bodyH = h * 0.32
|
||||
const hoodH = h - bodyH
|
||||
|
||||
return mergeGeometries(
|
||||
[buildBody(w, d, bodyH), buildDomeHood(w, d, overhang, bodyH, hoodH, 'dome')],
|
||||
false,
|
||||
) ?? buildBody(w, d, bodyH)
|
||||
return (
|
||||
mergeGeometries(
|
||||
[buildBody(w, d, bodyH), buildDomeHood(w, d, overhang, bodyH, hoodH, 'dome')],
|
||||
false,
|
||||
) ?? buildBody(w, d, bodyH)
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Body ────────────────────────────────────────────────────────────
|
||||
@@ -317,25 +408,60 @@ function buildBody(w: number, d: number, bodyH: number): THREE.BufferGeometry {
|
||||
const uvs: number[] = []
|
||||
|
||||
// +X side
|
||||
pushQuad(positions, normals, uvs,
|
||||
[hw, 0, -hd], [hw, 0, hd], [hw, bodyH, hd], [hw, bodyH, -hd],
|
||||
[1, 0, 0])
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[hw, 0, -hd],
|
||||
[hw, 0, hd],
|
||||
[hw, bodyH, hd],
|
||||
[hw, bodyH, -hd],
|
||||
[1, 0, 0],
|
||||
)
|
||||
// -X side
|
||||
pushQuad(positions, normals, uvs,
|
||||
[-hw, 0, hd], [-hw, 0, -hd], [-hw, bodyH, -hd], [-hw, bodyH, hd],
|
||||
[-1, 0, 0])
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[-hw, 0, hd],
|
||||
[-hw, 0, -hd],
|
||||
[-hw, bodyH, -hd],
|
||||
[-hw, bodyH, hd],
|
||||
[-1, 0, 0],
|
||||
)
|
||||
// +Z side
|
||||
pushQuad(positions, normals, uvs,
|
||||
[hw, 0, hd], [-hw, 0, hd], [-hw, bodyH, hd], [hw, bodyH, hd],
|
||||
[0, 0, 1])
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[hw, 0, hd],
|
||||
[-hw, 0, hd],
|
||||
[-hw, bodyH, hd],
|
||||
[hw, bodyH, hd],
|
||||
[0, 0, 1],
|
||||
)
|
||||
// -Z side
|
||||
pushQuad(positions, normals, uvs,
|
||||
[-hw, 0, -hd], [hw, 0, -hd], [hw, bodyH, -hd], [-hw, bodyH, -hd],
|
||||
[0, 0, -1])
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[-hw, 0, -hd],
|
||||
[hw, 0, -hd],
|
||||
[hw, bodyH, -hd],
|
||||
[-hw, bodyH, -hd],
|
||||
[0, 0, -1],
|
||||
)
|
||||
// Bottom (closes the body so it reads as solid from below)
|
||||
pushQuad(positions, normals, uvs,
|
||||
[-hw, 0, -hd], [-hw, 0, hd], [hw, 0, hd], [hw, 0, -hd],
|
||||
[0, -1, 0])
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[-hw, 0, -hd],
|
||||
[-hw, 0, hd],
|
||||
[hw, 0, hd],
|
||||
[hw, 0, -hd],
|
||||
[0, -1, 0],
|
||||
)
|
||||
|
||||
return buildBufferGeometry(positions, normals, uvs)
|
||||
}
|
||||
@@ -369,9 +495,16 @@ function buildDomeHood(
|
||||
const y0 = bodyH
|
||||
|
||||
// Skirt underside
|
||||
pushQuad(positions, normals, uvs,
|
||||
[-bw, y0, -bd], [-bw, y0, bd], [bw, y0, bd], [bw, y0, -bd],
|
||||
[0, -1, 0])
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[-bw, y0, -bd],
|
||||
[-bw, y0, bd],
|
||||
[bw, y0, bd],
|
||||
[bw, y0, -bd],
|
||||
[0, -1, 0],
|
||||
)
|
||||
|
||||
// Sample a low-resolution dome on a lat × lng grid. The radial decay
|
||||
// is `cos(phi) ^ radialPower` — `radialPower < 1` keeps the dome wide
|
||||
@@ -385,10 +518,10 @@ function buildDomeHood(
|
||||
for (let i = 0; i <= lat; i++) {
|
||||
const row: THREE.Vector3[] = []
|
||||
const phi = (Math.PI / 2) * (i / lat)
|
||||
const r = Math.pow(Math.cos(phi), radialPower)
|
||||
const r = Math.cos(phi) ** radialPower
|
||||
const y = y0 + hoodH * Math.sin(phi)
|
||||
for (let j = 0; j <= lng; j++) {
|
||||
const theta = (Math.PI * 2) * (j / lng)
|
||||
const theta = Math.PI * 2 * (j / lng)
|
||||
const x = bw * r * Math.cos(theta)
|
||||
const z = bd * r * Math.sin(theta)
|
||||
row.push(new THREE.Vector3(x, y, z))
|
||||
@@ -410,9 +543,16 @@ function buildDomeHood(
|
||||
// winding (see note in `pushQuad`). Swapping the cross operands here
|
||||
// keeps the dome lit from the outside, not from inside.
|
||||
const n = new THREE.Vector3().crossVectors(ad, ab).normalize()
|
||||
pushQuad(positions, normals, uvs,
|
||||
[a.x, a.y, a.z], [b.x, b.y, b.z], [c.x, c.y, c.z], [d2.x, d2.y, d2.z],
|
||||
[n.x, n.y, n.z])
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[a.x, a.y, a.z],
|
||||
[b.x, b.y, b.z],
|
||||
[c.x, c.y, c.z],
|
||||
[d2.x, d2.y, d2.z],
|
||||
[n.x, n.y, n.z],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -514,9 +654,7 @@ function pushTri(
|
||||
* source of truth.
|
||||
*/
|
||||
export function computeBoxVentSlopeTilt(
|
||||
segment:
|
||||
| { roofType: RoofType; pitch: number; width: number; depth: number }
|
||||
| undefined,
|
||||
segment: { roofType: RoofType; pitch: number; width: number; depth: number } | undefined,
|
||||
localZ: number,
|
||||
): number {
|
||||
if (!segment || segment.roofType === 'flat' || localZ === 0) return 0
|
||||
|
||||
@@ -14,11 +14,8 @@ import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/edito
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import {
|
||||
getAnalyticalNormal,
|
||||
surfaceQuatFromNormal,
|
||||
} from '../solar-panel/geometry'
|
||||
import { resolveRoofSegmentHit } from '../roof/segment-hit'
|
||||
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../solar-panel/geometry'
|
||||
import BoxVentPreview from './preview'
|
||||
|
||||
/**
|
||||
@@ -36,8 +33,7 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) {
|
||||
}, [])
|
||||
|
||||
const [previewPos, setPreviewPos] = useState<[number, number, number] | null>(null)
|
||||
const [previewSurfaceQuat, setPreviewSurfaceQuat] =
|
||||
useState<THREE.Quaternion | null>(null)
|
||||
const [previewSurfaceQuat, setPreviewSurfaceQuat] = useState<THREE.Quaternion | null>(null)
|
||||
const [previewYaw, setPreviewYaw] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -59,15 +55,9 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) {
|
||||
const ventObj = sceneRegistry.nodes.get(node.id)
|
||||
if (ventObj) ventObj.visible = false
|
||||
|
||||
const worldToBuildingLocal = (
|
||||
wx: number,
|
||||
wy: number,
|
||||
wz: number,
|
||||
): [number, number, number] => {
|
||||
const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => {
|
||||
const buildingId = useViewer.getState().selection.buildingId
|
||||
const buildingObj = buildingId
|
||||
? sceneRegistry.nodes.get(buildingId as AnyNodeId)
|
||||
: null
|
||||
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
|
||||
if (!buildingObj) return [wx, wy, wz]
|
||||
const v = new THREE.Vector3(wx, wy, wz)
|
||||
buildingObj.worldToLocal(v)
|
||||
@@ -158,9 +148,7 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) {
|
||||
// user doesn't end up with an orphan they didn't intend to place.
|
||||
const parentId = original.roofSegmentId as AnyNodeId | undefined
|
||||
if (parentId) {
|
||||
const parent = useScene.getState().nodes[parentId] as
|
||||
| RoofSegmentNode
|
||||
| undefined
|
||||
const parent = useScene.getState().nodes[parentId] as RoofSegmentNode | undefined
|
||||
if (parent) {
|
||||
useScene.getState().updateNode(parentId, {
|
||||
children: (parent.children ?? []).filter((id) => id !== node.id),
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import type { BoxVentNode } from './schema'
|
||||
import {
|
||||
ActionButton,
|
||||
ActionGroup,
|
||||
@@ -23,6 +22,7 @@ import {
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Copy, Move, Trash2 } from 'lucide-react'
|
||||
import { useCallback } from 'react'
|
||||
import type { BoxVentNode } from './schema'
|
||||
|
||||
/**
|
||||
* Inspector panel for a placed box-vent. Exposes the same parametrics
|
||||
@@ -296,10 +296,7 @@ export default function BoxVentPanel() {
|
||||
/>
|
||||
<SliderControl
|
||||
label="Gap Height"
|
||||
max={Math.max(
|
||||
0,
|
||||
node.height - Math.max(0.01, node.capHeight ?? 0.07) - 0.005,
|
||||
)}
|
||||
max={Math.max(0, node.height - Math.max(0.01, node.capHeight ?? 0.07) - 0.005)}
|
||||
min={0}
|
||||
onChange={(v) => previewProp({ capGap: v })}
|
||||
onCommit={(v) => handleUpdate({ capGap: v })}
|
||||
|
||||
@@ -16,13 +16,10 @@ import type { BoxVentNode } from './schema'
|
||||
* the cursor ray and starve the placement tool of `roof:move` events.
|
||||
*/
|
||||
const BoxVentPreview = ({ node }: { node: BoxVentNode }) => {
|
||||
const geometry = useMemo(() => buildBoxVentGeometry(node), [
|
||||
node.width,
|
||||
node.depth,
|
||||
node.height,
|
||||
node.hoodOverhang,
|
||||
node.style,
|
||||
])
|
||||
const geometry = useMemo(
|
||||
() => buildBoxVentGeometry(node),
|
||||
[node.width, node.depth, node.height, node.hoodOverhang, node.style],
|
||||
)
|
||||
|
||||
const material = useMemo(
|
||||
() =>
|
||||
@@ -61,12 +58,7 @@ const BoxVentPreview = ({ node }: { node: BoxVentNode }) => {
|
||||
}}
|
||||
/>
|
||||
<lineSegments geometry={edgesGeometry} renderOrder={1000}>
|
||||
<lineBasicMaterial
|
||||
color={0x6c_a3_ff}
|
||||
depthTest={false}
|
||||
opacity={0.95}
|
||||
transparent
|
||||
/>
|
||||
<lineBasicMaterial color={0x6c_a3_ff} depthTest={false} opacity={0.95} transparent />
|
||||
</lineSegments>
|
||||
</group>
|
||||
)
|
||||
|
||||
@@ -8,7 +8,14 @@ import {
|
||||
useRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { createMaterial, createMaterialFromPresetRef, useNodeEvents } from '@pascal-app/viewer'
|
||||
import {
|
||||
type ColorPreset,
|
||||
createMaterial,
|
||||
createMaterialFromPresetRef,
|
||||
createSurfaceRoleMaterial,
|
||||
useNodeEvents,
|
||||
useViewer,
|
||||
} from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../solar-panel/geometry'
|
||||
@@ -45,13 +52,17 @@ const BoxVentRenderer = ({ node: storeNode }: { node: BoxVentNode }) => {
|
||||
const ref = useRef<THREE.Group>(null!)
|
||||
useRegistry(storeNode.id, 'box-vent', ref)
|
||||
const handlers = useNodeEvents(storeNode, 'box-vent')
|
||||
const shading = useViewer((s) => s.shading)
|
||||
const textures = useViewer((s) => s.textures)
|
||||
const colorPreset: ColorPreset = useViewer((s) => s.colorPreset)
|
||||
const sceneTheme = useViewer((s) => s.sceneTheme)
|
||||
|
||||
// Merge live overrides (panel slider drags) on top of the store node.
|
||||
// Sliders write here on every `onChange` and only flush to the scene
|
||||
// store on `onCommit`, so the mesh updates frame-by-frame without
|
||||
// polluting undo history or triggering a full store-driven re-render.
|
||||
const overrides = useLiveNodeOverrides((s) =>
|
||||
s.get(storeNode.id as AnyNodeId) as Partial<BoxVentNode> | undefined,
|
||||
const overrides = useLiveNodeOverrides(
|
||||
(s) => s.get(storeNode.id as AnyNodeId) as Partial<BoxVentNode> | undefined,
|
||||
)
|
||||
const node: BoxVentNode = overrides ? ({ ...storeNode, ...overrides } as BoxVentNode) : storeNode
|
||||
|
||||
@@ -65,20 +76,23 @@ const BoxVentRenderer = ({ node: storeNode }: { node: BoxVentNode }) => {
|
||||
// every parametric field, including the per-style ones. Listing them
|
||||
// explicitly keeps the dep array tight (vs. `[node]` which would
|
||||
// also fire on `name` / `visible` flips).
|
||||
const geometry = useMemo(() => buildBoxVentGeometry(node), [
|
||||
node.style,
|
||||
node.width,
|
||||
node.depth,
|
||||
node.height,
|
||||
node.hoodOverhang,
|
||||
node.topTaper,
|
||||
node.capHeight,
|
||||
node.capGap,
|
||||
node.domeCurvature,
|
||||
node.baseInset,
|
||||
node.baseHeight,
|
||||
node.cornerBevel,
|
||||
])
|
||||
const geometry = useMemo(
|
||||
() => buildBoxVentGeometry(node),
|
||||
[
|
||||
node.style,
|
||||
node.width,
|
||||
node.depth,
|
||||
node.height,
|
||||
node.hoodOverhang,
|
||||
node.topTaper,
|
||||
node.capHeight,
|
||||
node.capGap,
|
||||
node.domeCurvature,
|
||||
node.baseInset,
|
||||
node.baseHeight,
|
||||
node.cornerBevel,
|
||||
],
|
||||
)
|
||||
|
||||
useEffect(() => () => geometry.dispose(), [geometry])
|
||||
|
||||
@@ -89,11 +103,7 @@ const BoxVentRenderer = ({ node: storeNode }: { node: BoxVentNode }) => {
|
||||
// ran along segment-local Z.
|
||||
const surfaceQuat = useMemo(() => {
|
||||
if (!segment) return new THREE.Quaternion()
|
||||
const normal = getAnalyticalNormal(
|
||||
node.position[0] ?? 0,
|
||||
node.position[2] ?? 0,
|
||||
segment,
|
||||
)
|
||||
const normal = getAnalyticalNormal(node.position[0] ?? 0, node.position[2] ?? 0, segment)
|
||||
return surfaceQuatFromNormal(normal, new THREE.Quaternion())
|
||||
}, [segment, node.position[0], node.position[2]])
|
||||
|
||||
@@ -103,14 +113,20 @@ const BoxVentRenderer = ({ node: storeNode }: { node: BoxVentNode }) => {
|
||||
// DoubleSide locally so back faces of the vent body / hood don't drop
|
||||
// out when the camera looks up at the eaves.
|
||||
const material = useMemo(() => {
|
||||
// Untextured box vent (and textures-off mode) takes the themed 'roof'
|
||||
// role colour. Request DoubleSide directly so the cached role material
|
||||
// is the right side — no clone/mutation of a shared material.
|
||||
if (!textures || (!node.material && !node.materialPreset)) {
|
||||
return createSurfaceRoleMaterial('roof', colorPreset, THREE.DoubleSide, sceneTheme)
|
||||
}
|
||||
const base = node.material
|
||||
? createMaterial(node.material)
|
||||
: (createMaterialFromPresetRef(node.materialPreset) ?? defaultMaterial)
|
||||
? createMaterial(node.material, shading)
|
||||
: (createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial)
|
||||
if (base.side === THREE.DoubleSide) return base
|
||||
const cloned = base.clone()
|
||||
cloned.side = THREE.DoubleSide
|
||||
return cloned
|
||||
}, [node.material, node.materialPreset])
|
||||
}, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset])
|
||||
|
||||
if (!segment) return null
|
||||
|
||||
|
||||
@@ -13,11 +13,8 @@ import { triggerSFX } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import {
|
||||
getAnalyticalNormal,
|
||||
surfaceQuatFromNormal,
|
||||
} from '../solar-panel/geometry'
|
||||
import { resolveRoofSegmentHit } from '../roof/segment-hit'
|
||||
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../solar-panel/geometry'
|
||||
import { boxVentDefinition } from './definition'
|
||||
import BoxVentPreview from './preview'
|
||||
|
||||
@@ -57,11 +54,7 @@ const BoxVentTool = () => {
|
||||
useEffect(() => {
|
||||
if (!activeBuildingId) return
|
||||
|
||||
const worldToBuildingLocal = (
|
||||
wx: number,
|
||||
wy: number,
|
||||
wz: number,
|
||||
): [number, number, number] => {
|
||||
const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => {
|
||||
const buildingObj = sceneRegistry.nodes.get(activeBuildingId as AnyNodeId)
|
||||
if (!buildingObj) return [wx, wy, wz]
|
||||
worldPoint.set(wx, wy, wz)
|
||||
|
||||
@@ -30,6 +30,7 @@ export const ceilingDefinition: NodeDefinition<typeof CeilingNode> = {
|
||||
schemaVersion: 1,
|
||||
schema: CeilingNode,
|
||||
category: 'structure',
|
||||
surfaceRole: 'ceiling',
|
||||
|
||||
defaults: () => ({
|
||||
object: 'node',
|
||||
|
||||
@@ -6,7 +6,12 @@ import {
|
||||
resolveMaterial,
|
||||
useRegistry,
|
||||
} from '@pascal-app/core'
|
||||
import { NodeRenderer, useNodeEvents } from '@pascal-app/viewer'
|
||||
import {
|
||||
createSurfaceRoleMaterial,
|
||||
NodeRenderer,
|
||||
useNodeEvents,
|
||||
useViewer,
|
||||
} from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { BufferGeometry, Float32BufferAttribute } from 'three'
|
||||
import { float, mix, positionWorld, smoothstep } from 'three/tsl'
|
||||
@@ -64,6 +69,9 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
|
||||
|
||||
useRegistry(node.id, 'ceiling', ref)
|
||||
const handlers = useNodeEvents(node, 'ceiling')
|
||||
const textures = useViewer((s) => s.textures)
|
||||
const colorPreset = useViewer((s) => s.colorPreset)
|
||||
const sceneTheme = useViewer((s) => s.sceneTheme)
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
@@ -74,11 +82,24 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
|
||||
)
|
||||
|
||||
const materials = useMemo(() => {
|
||||
// Untextured ceilings (and everything in textures-off mode) take the themed
|
||||
// 'ceiling' role colour; only an explicit preset/material keeps a texture.
|
||||
const hasExplicit = Boolean(node.materialPreset || node.material)
|
||||
if (!textures || !hasExplicit) {
|
||||
return {
|
||||
topMaterial: createSurfaceRoleMaterial('ceiling', colorPreset, FrontSide, sceneTheme),
|
||||
bottomMaterial: createSurfaceRoleMaterial('ceiling', colorPreset, BackSide, sceneTheme),
|
||||
}
|
||||
}
|
||||
|
||||
const preset = getMaterialPresetByRef(node.materialPreset)
|
||||
const props = preset?.mapProperties ?? resolveMaterial(node.material)
|
||||
const color = props.color || '#999999'
|
||||
return getCeilingMaterials(color)
|
||||
}, [
|
||||
textures,
|
||||
colorPreset,
|
||||
sceneTheme,
|
||||
node.materialPreset,
|
||||
node.material,
|
||||
node.material?.preset,
|
||||
|
||||
@@ -27,7 +27,10 @@ const fixtureSegment = (): RoofSegmentNode =>
|
||||
|
||||
describe('buildChimneyGeometry', () => {
|
||||
test('returns body for default chimney with a non-empty position attribute', () => {
|
||||
const { body, cap, flues, cricket } = buildChimneyGeometry(ChimneyNode.parse({}), fixtureSegment())
|
||||
const { body, cap, flues, cricket } = buildChimneyGeometry(
|
||||
ChimneyNode.parse({}),
|
||||
fixtureSegment(),
|
||||
)
|
||||
expect(body.getAttribute('position').count).toBeGreaterThan(0)
|
||||
expect(cap?.getAttribute('position').count).toBeGreaterThan(0)
|
||||
expect(flues?.getAttribute('position').count).toBeGreaterThan(0)
|
||||
@@ -43,18 +46,12 @@ describe('buildChimneyGeometry', () => {
|
||||
})
|
||||
|
||||
test('cap omitted when cap=false', () => {
|
||||
const { cap } = buildChimneyGeometry(
|
||||
ChimneyNode.parse({ cap: false }),
|
||||
fixtureSegment(),
|
||||
)
|
||||
const { cap } = buildChimneyGeometry(ChimneyNode.parse({ cap: false }), fixtureSegment())
|
||||
expect(cap).toBeNull()
|
||||
})
|
||||
|
||||
test('flues omitted when flueCount=0', () => {
|
||||
const { flues } = buildChimneyGeometry(
|
||||
ChimneyNode.parse({ flueCount: 0 }),
|
||||
fixtureSegment(),
|
||||
)
|
||||
const { flues } = buildChimneyGeometry(ChimneyNode.parse({ flueCount: 0 }), fixtureSegment())
|
||||
expect(flues).toBeNull()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { CHIMNEY_PRESET_KEYS, chimneyPresets, detectActiveChimneyPreset } from '../presets'
|
||||
import { ChimneyNode } from '../schema'
|
||||
import {
|
||||
CHIMNEY_PRESET_KEYS,
|
||||
chimneyPresets,
|
||||
detectActiveChimneyPreset,
|
||||
} from '../presets'
|
||||
|
||||
// Build a fully-formed chimney by parsing an empty object (schema fills
|
||||
// every default) and merging the preset over the top — mirrors what the
|
||||
@@ -36,9 +32,7 @@ describe('detectActiveChimneyPreset', () => {
|
||||
const node = applyPreset('brick')
|
||||
// Brick preset sets bandStyle=double; flip it to confirm the
|
||||
// detection narrows.
|
||||
expect(
|
||||
detectActiveChimneyPreset({ ...node, bandStyle: 'single' as const }),
|
||||
).toBeNull()
|
||||
expect(detectActiveChimneyPreset({ ...node, bandStyle: 'single' as const })).toBeNull()
|
||||
})
|
||||
|
||||
test('ignores non-preset fields (dimensions, materials, placement)', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type NodeDefinition, ChimneyNode as ChimneyNodeSchema } from '@pascal-app/core'
|
||||
import { ChimneyNode as ChimneyNodeSchema, type NodeDefinition } from '@pascal-app/core'
|
||||
import { chimneyPaint } from './paint'
|
||||
import { chimneyParametrics } from './parametrics'
|
||||
import { ChimneyNode } from './schema'
|
||||
@@ -36,6 +36,7 @@ export const chimneyDefinition: NodeDefinition<typeof ChimneyNode> = {
|
||||
schemaVersion: 1,
|
||||
schema: ChimneyNode,
|
||||
category: 'structure',
|
||||
surfaceRole: 'wall',
|
||||
|
||||
defaults: () => {
|
||||
const stub = ChimneyNodeSchema.parse({
|
||||
|
||||
@@ -65,10 +65,7 @@ function mergeAndDispose(parts: THREE.BufferGeometry[]): THREE.BufferGeometry {
|
||||
return merged
|
||||
}
|
||||
|
||||
export function buildChimneyGeometry(
|
||||
node: ChimneyNode,
|
||||
segment: RoofSegmentNode,
|
||||
): ChimneyGeometry {
|
||||
export function buildChimneyGeometry(node: ChimneyNode, segment: RoofSegmentNode): ChimneyGeometry {
|
||||
const peakY = segment.wallHeight + getActiveRoofHeight(segment)
|
||||
const topY = peakY + node.heightAboveRidge
|
||||
// Embed the body 0.2m below the eave so the bottom isn't visible
|
||||
@@ -107,11 +104,7 @@ export function buildChimneyGeometry(
|
||||
|
||||
// ─── Body ────────────────────────────────────────────────────────────
|
||||
|
||||
function buildBodyGeometry(
|
||||
node: ChimneyNode,
|
||||
baseY: number,
|
||||
topY: number,
|
||||
): THREE.BufferGeometry {
|
||||
function buildBodyGeometry(node: ChimneyNode, baseY: number, topY: number): THREE.BufferGeometry {
|
||||
const isRound = node.bodyShape === 'round'
|
||||
const w = node.width
|
||||
const d = isRound ? node.width : node.depth
|
||||
@@ -333,10 +326,7 @@ function buildFluesGeometry(node: ChimneyNode, capTopY: number): THREE.BufferGeo
|
||||
// ─── Cricket ─────────────────────────────────────────────────────────
|
||||
// Water-shedding wedge on the up-slope side of the chimney.
|
||||
|
||||
function buildCricketGeometry(
|
||||
node: ChimneyNode,
|
||||
baseY: number,
|
||||
): THREE.BufferGeometry {
|
||||
function buildCricketGeometry(node: ChimneyNode, baseY: number): THREE.BufferGeometry {
|
||||
const w = node.width
|
||||
const d = node.depth
|
||||
const cL = Math.max(0.1, node.cricketLength)
|
||||
@@ -365,19 +355,32 @@ function buildCricketGeometry(
|
||||
const u0_: [number, number] = [0, 0]
|
||||
const u1_: [number, number] = [w, 0]
|
||||
const uvBottom: Record<'v0' | 'v1' | 'v2' | 'v3', [number, number]> = {
|
||||
v0: u0_, v1: u1_, v2: [w, cL], v3: [0, cL],
|
||||
v0: u0_,
|
||||
v1: u1_,
|
||||
v2: [w, cL],
|
||||
v3: [0, cL],
|
||||
}
|
||||
const uvSlope: Record<'v3' | 'v2' | 'v5' | 'v4', [number, number]> = {
|
||||
v3: [0, 0], v2: [w, 0], v5: [w, slopeLen], v4: [0, slopeLen],
|
||||
v3: [0, 0],
|
||||
v2: [w, 0],
|
||||
v5: [w, slopeLen],
|
||||
v4: [0, slopeLen],
|
||||
}
|
||||
const uvBack: Record<'v0' | 'v1' | 'v5' | 'v4', [number, number]> = {
|
||||
v0: [0, 0], v1: [w, 0], v5: [w, cH], v4: [0, cH],
|
||||
v0: [0, 0],
|
||||
v1: [w, 0],
|
||||
v5: [w, cH],
|
||||
v4: [0, cH],
|
||||
}
|
||||
const uvLeft: Record<'v0' | 'v3' | 'v4', [number, number]> = {
|
||||
v0: [0, 0], v3: [cL, 0], v4: [0, cH],
|
||||
v0: [0, 0],
|
||||
v3: [cL, 0],
|
||||
v4: [0, cH],
|
||||
}
|
||||
const uvRight: Record<'v1' | 'v5' | 'v2', [number, number]> = {
|
||||
v1: [0, 0], v5: [0, cH], v2: [cL, 0],
|
||||
v1: [0, 0],
|
||||
v5: [0, cH],
|
||||
v2: [cL, 0],
|
||||
}
|
||||
|
||||
const pushTri = (
|
||||
@@ -510,9 +513,7 @@ function pushSlabFaces(
|
||||
const cB = Math.max(0, Math.min(bevel, halfWB - 0.001, halfDB - 0.001))
|
||||
const cT = Math.max(0, Math.min(bevel, halfWT - 0.001, halfDT - 0.001))
|
||||
if (cB > 0.001 || cT > 0.001) {
|
||||
pushOctagonalSlabFaces(
|
||||
positions, uvs, y0, y1, halfWB, halfDB, halfWT, halfDT, cB, cT,
|
||||
)
|
||||
pushOctagonalSlabFaces(positions, uvs, y0, y1, halfWB, halfDB, halfWT, halfDT, cB, cT)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -541,11 +542,27 @@ function pushSlabFaces(
|
||||
}
|
||||
|
||||
// Bottom
|
||||
pushQuad(bBL, bTL, bTR, bBR,
|
||||
[-halfWB, -halfDB], [-halfWB, halfDB], [halfWB, halfDB], [halfWB, -halfDB])
|
||||
pushQuad(
|
||||
bBL,
|
||||
bTL,
|
||||
bTR,
|
||||
bBR,
|
||||
[-halfWB, -halfDB],
|
||||
[-halfWB, halfDB],
|
||||
[halfWB, halfDB],
|
||||
[halfWB, -halfDB],
|
||||
)
|
||||
// Top
|
||||
pushQuad(tBL, tBR, tTR, tTL,
|
||||
[-halfWT, -halfDT], [halfWT, -halfDT], [halfWT, halfDT], [-halfWT, halfDT])
|
||||
pushQuad(
|
||||
tBL,
|
||||
tBR,
|
||||
tTR,
|
||||
tTL,
|
||||
[-halfWT, -halfDT],
|
||||
[halfWT, -halfDT],
|
||||
[halfWT, halfDT],
|
||||
[-halfWT, halfDT],
|
||||
)
|
||||
// Sides
|
||||
pushQuad(bBL, bBR, tBR, tBL, [-halfWB, 0], [halfWB, 0], [halfWT, t], [-halfWT, t])
|
||||
pushQuad(bBR, bTR, tTR, tBR, [-halfDB, 0], [halfDB, 0], [halfDT, t], [-halfDT, t])
|
||||
|
||||
@@ -313,10 +313,7 @@ function buildCutter(
|
||||
return brush
|
||||
}
|
||||
|
||||
function subtractCutters(
|
||||
base: THREE.BufferGeometry,
|
||||
cutters: Brush[],
|
||||
): THREE.BufferGeometry {
|
||||
function subtractCutters(base: THREE.BufferGeometry, cutters: Brush[]): THREE.BufferGeometry {
|
||||
if (cutters.length === 0) return base
|
||||
|
||||
const indexed = mergeVertices(base, 1e-4)
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
ChimneyNode as ChimneyNodeSchema,
|
||||
type ChimneyNode,
|
||||
ChimneyNode as ChimneyNodeSchema,
|
||||
emitter,
|
||||
type RoofEvent,
|
||||
type RoofNode,
|
||||
|
||||
@@ -11,10 +11,6 @@ import {
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { Vector3 } from 'three'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import {
|
||||
ActionButton,
|
||||
ActionGroup,
|
||||
@@ -24,6 +20,10 @@ import {
|
||||
SliderControl,
|
||||
triggerSFX,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { Vector3 } from 'three'
|
||||
import {
|
||||
CHIMNEY_PRESET_KEYS,
|
||||
CHIMNEY_PRESET_LABELS,
|
||||
@@ -278,8 +278,9 @@ export default function ChimneyPanel() {
|
||||
let newOriginWorldY = 0
|
||||
if (newSegObj) {
|
||||
newSegObj.updateWorldMatrix(true, false)
|
||||
newOriginWorldY = new Vector3(target.localX, 0, target.localZ)
|
||||
.applyMatrix4(newSegObj.matrixWorld).y
|
||||
newOriginWorldY = new Vector3(target.localX, 0, target.localZ).applyMatrix4(
|
||||
newSegObj.matrixWorld,
|
||||
).y
|
||||
}
|
||||
const newHeightAboveRidge = Math.max(0.1, oldWorldTopY - newOriginWorldY - newPeakY)
|
||||
|
||||
@@ -886,7 +887,6 @@ export default function ChimneyPanel() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Actions">
|
||||
|
||||
@@ -21,43 +21,40 @@ const ghostMaterial = new THREE.MeshStandardMaterial({
|
||||
* segment is hit, the preview isn't shown at all (the tool guards on
|
||||
* `previewPos`).
|
||||
*/
|
||||
const ChimneyPreview = ({
|
||||
node,
|
||||
segment,
|
||||
}: {
|
||||
node: ChimneyNode
|
||||
segment: RoofSegmentNode
|
||||
}) => {
|
||||
const geo = useMemo(() => buildChimneyGeometry(node, segment), [
|
||||
segment.wallHeight,
|
||||
segment.pitch,
|
||||
segment.roofType,
|
||||
segment.width,
|
||||
segment.depth,
|
||||
node.width,
|
||||
node.depth,
|
||||
node.heightAboveRidge,
|
||||
node.bodyShape,
|
||||
node.shoulderStyle,
|
||||
node.shoulderHeight,
|
||||
node.shoulderExtent,
|
||||
node.cap,
|
||||
node.capShape,
|
||||
node.capOverhang,
|
||||
node.capThickness,
|
||||
node.flueCount,
|
||||
node.flueShape,
|
||||
node.flueHeight,
|
||||
node.flueDiameter,
|
||||
node.flueSpacing,
|
||||
node.cricketStyle,
|
||||
node.cricketSide,
|
||||
node.cricketLength,
|
||||
node.cricketHeight,
|
||||
node.position[0],
|
||||
node.position[2],
|
||||
node.rotation,
|
||||
])
|
||||
const ChimneyPreview = ({ node, segment }: { node: ChimneyNode; segment: RoofSegmentNode }) => {
|
||||
const geo = useMemo(
|
||||
() => buildChimneyGeometry(node, segment),
|
||||
[
|
||||
segment.wallHeight,
|
||||
segment.pitch,
|
||||
segment.roofType,
|
||||
segment.width,
|
||||
segment.depth,
|
||||
node.width,
|
||||
node.depth,
|
||||
node.heightAboveRidge,
|
||||
node.bodyShape,
|
||||
node.shoulderStyle,
|
||||
node.shoulderHeight,
|
||||
node.shoulderExtent,
|
||||
node.cap,
|
||||
node.capShape,
|
||||
node.capOverhang,
|
||||
node.capThickness,
|
||||
node.flueCount,
|
||||
node.flueShape,
|
||||
node.flueHeight,
|
||||
node.flueDiameter,
|
||||
node.flueSpacing,
|
||||
node.cricketStyle,
|
||||
node.cricketSide,
|
||||
node.cricketLength,
|
||||
node.cricketHeight,
|
||||
node.position[0],
|
||||
node.position[2],
|
||||
node.rotation,
|
||||
],
|
||||
)
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
|
||||
@@ -9,10 +9,13 @@ import {
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
type ColorPreset,
|
||||
createMaterial,
|
||||
createMaterialFromPresetRef,
|
||||
createSurfaceRoleMaterial,
|
||||
getRoofSegmentBrushes,
|
||||
useNodeEvents,
|
||||
useViewer,
|
||||
} from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
@@ -35,12 +38,16 @@ const ChimneyRenderer = ({ node: storeNode }: { node: ChimneyNode }) => {
|
||||
const ref = useRef<THREE.Group>(null!)
|
||||
useRegistry(storeNode.id, 'chimney', ref)
|
||||
const handlers = useNodeEvents(storeNode, 'chimney')
|
||||
const shading = useViewer((s) => s.shading)
|
||||
const textures = useViewer((s) => s.textures)
|
||||
const colorPreset: ColorPreset = useViewer((s) => s.colorPreset)
|
||||
const sceneTheme = useViewer((s) => s.sceneTheme)
|
||||
|
||||
// Merge in-flight slider drags from `useLiveNodeOverrides` so the mesh
|
||||
// updates while the user is still holding the slider. On release the
|
||||
// panel commits to the store and clears the override.
|
||||
const overrides = useLiveNodeOverrides((state) =>
|
||||
state.get(storeNode.id as AnyNodeId) as Partial<ChimneyNode> | undefined,
|
||||
const overrides = useLiveNodeOverrides(
|
||||
(state) => state.get(storeNode.id as AnyNodeId) as Partial<ChimneyNode> | undefined,
|
||||
)
|
||||
const node = useMemo<ChimneyNode>(
|
||||
() => (overrides ? { ...storeNode, ...overrides } : storeNode),
|
||||
@@ -157,19 +164,44 @@ const ChimneyRenderer = ({ node: storeNode }: { node: ChimneyNode }) => {
|
||||
)
|
||||
|
||||
const surfaceMaterial = useMemo(() => {
|
||||
if (node.material) return createMaterial(node.material)
|
||||
const preset = createMaterialFromPresetRef(node.materialPreset)
|
||||
return preset ?? fallbackBodyMaterial
|
||||
}, [node.material, node.materialPreset, fallbackBodyMaterial])
|
||||
// Untextured chimney body (and everything in textures-off mode) takes
|
||||
// the themed 'wall' role colour; only an explicit preset/material keeps
|
||||
// its texture when textures are on.
|
||||
if (!textures || (!node.material && !node.materialPreset)) {
|
||||
return createSurfaceRoleMaterial('wall', colorPreset, undefined, sceneTheme)
|
||||
}
|
||||
if (node.material) return createMaterial(node.material, shading)
|
||||
return createMaterialFromPresetRef(node.materialPreset, shading) ?? fallbackBodyMaterial
|
||||
}, [
|
||||
textures,
|
||||
colorPreset,
|
||||
sceneTheme,
|
||||
shading,
|
||||
node.material,
|
||||
node.materialPreset,
|
||||
fallbackBodyMaterial,
|
||||
])
|
||||
|
||||
const capSurfaceMaterial = useMemo(() => {
|
||||
if (node.topMaterial) return createMaterial(node.topMaterial)
|
||||
const preset = createMaterialFromPresetRef(node.topMaterialPreset)
|
||||
// Cap/crown is the chimney's roof-facing surface → 'roof' role when
|
||||
// untextured (or textures off). Otherwise resolve the explicit cap
|
||||
// material, then fall back to the body material.
|
||||
if (
|
||||
!textures ||
|
||||
(!node.topMaterial && !node.topMaterialPreset && !node.material && !node.materialPreset)
|
||||
) {
|
||||
return createSurfaceRoleMaterial('roof', colorPreset, undefined, sceneTheme)
|
||||
}
|
||||
if (node.topMaterial) return createMaterial(node.topMaterial, shading)
|
||||
const preset = createMaterialFromPresetRef(node.topMaterialPreset, shading)
|
||||
if (preset) return preset
|
||||
if (node.material) return createMaterial(node.material)
|
||||
const bodyPreset = createMaterialFromPresetRef(node.materialPreset)
|
||||
return bodyPreset ?? fallbackTopMaterial
|
||||
if (node.material) return createMaterial(node.material, shading)
|
||||
return createMaterialFromPresetRef(node.materialPreset, shading) ?? fallbackTopMaterial
|
||||
}, [
|
||||
textures,
|
||||
colorPreset,
|
||||
sceneTheme,
|
||||
shading,
|
||||
node.topMaterial,
|
||||
node.topMaterialPreset,
|
||||
node.material,
|
||||
|
||||
@@ -49,9 +49,9 @@ export function trimChimneyBodyAgainstRoof(
|
||||
const indexCount = indexed.getIndex()?.count ?? 0
|
||||
indexed.clearGroups()
|
||||
if (indexCount > 0) indexed.addGroup(0, indexCount, 0)
|
||||
;(indexed as unknown as { computeBoundsTree?: (opts: { maxLeafSize: number }) => void }).computeBoundsTree?.(
|
||||
{ maxLeafSize: 10 },
|
||||
)
|
||||
;(
|
||||
indexed as unknown as { computeBoundsTree?: (opts: { maxLeafSize: number }) => void }
|
||||
).computeBoundsTree?.({ maxLeafSize: 10 })
|
||||
|
||||
const chimneyBrush = new Brush(indexed, visibleMat as unknown as THREE.MeshStandardMaterial)
|
||||
chimneyBrush.updateMatrixWorld()
|
||||
|
||||
@@ -27,6 +27,7 @@ export const columnDefinition: NodeDefinition<typeof ColumnNode> = {
|
||||
schemaVersion: 1,
|
||||
schema: ColumnNode,
|
||||
category: 'structure',
|
||||
surfaceRole: 'wall',
|
||||
|
||||
defaults: () => {
|
||||
const stub = ColumnNodeSchema.parse({ id: 'column_default' as never, type: 'column' })
|
||||
|
||||
@@ -3,18 +3,22 @@
|
||||
import { type ColumnNode, useLiveTransforms, useRegistry } from '@pascal-app/core'
|
||||
import {
|
||||
baseMaterial,
|
||||
type ColorPreset,
|
||||
createColumnBoxGeometry,
|
||||
createColumnCylinderGeometry,
|
||||
createColumnSphereGeometry,
|
||||
createColumnTorusGeometry,
|
||||
createMaterial,
|
||||
createMaterialFromPresetRef,
|
||||
createSurfaceRoleMaterial,
|
||||
type RenderShading,
|
||||
useNodeEvents,
|
||||
useViewer,
|
||||
} from '@pascal-app/viewer'
|
||||
import { createContext, useContext, useMemo, useRef } from 'react'
|
||||
import { BufferGeometry, Float32BufferAttribute, type Group, type Material } from 'three'
|
||||
|
||||
const ColumnMaterialContext = createContext<Material>(baseMaterial as Material)
|
||||
const ColumnMaterialContext = createContext<Material>(baseMaterial())
|
||||
const ColumnEdgeSoftnessContext = createContext(0.025)
|
||||
|
||||
function ColumnMaterial() {
|
||||
@@ -25,11 +29,20 @@ function ColumnMaterial() {
|
||||
function createColumnMaterial({
|
||||
material,
|
||||
materialPreset,
|
||||
}: Pick<ColumnNode, 'material' | 'materialPreset'>) {
|
||||
const presetMaterial = createMaterialFromPresetRef(materialPreset)
|
||||
shading,
|
||||
textures,
|
||||
colorPreset,
|
||||
}: Pick<ColumnNode, 'material' | 'materialPreset'> & {
|
||||
shading: RenderShading
|
||||
textures: boolean
|
||||
colorPreset: ColorPreset
|
||||
}) {
|
||||
if (!textures) return createSurfaceRoleMaterial('wall', colorPreset)
|
||||
|
||||
const presetMaterial = createMaterialFromPresetRef(materialPreset, shading)
|
||||
if (presetMaterial) return presetMaterial
|
||||
if (material) return createMaterial(material)
|
||||
return baseMaterial
|
||||
if (material) return createMaterial(material, shading)
|
||||
return baseMaterial(shading)
|
||||
}
|
||||
|
||||
function getSegments(node: ColumnNode) {
|
||||
@@ -118,7 +131,7 @@ function MappedBox({
|
||||
if (!geometry) return null
|
||||
|
||||
return (
|
||||
<mesh dispose={null} position={position} rotation={rotation}>
|
||||
<mesh castShadow dispose={null} position={position} receiveShadow rotation={rotation}>
|
||||
<primitive attach="geometry" dispose={null} object={geometry} />
|
||||
<ColumnMaterial />
|
||||
</mesh>
|
||||
@@ -226,7 +239,7 @@ function FlatEndedBeam({
|
||||
if (!geometry) return null
|
||||
|
||||
return (
|
||||
<mesh dispose={null}>
|
||||
<mesh castShadow dispose={null} receiveShadow>
|
||||
<primitive attach="geometry" dispose={null} object={geometry} />
|
||||
<ColumnMaterial />
|
||||
</mesh>
|
||||
@@ -760,7 +773,7 @@ function MappedCylinder({
|
||||
if (!geometry) return null
|
||||
|
||||
return (
|
||||
<mesh dispose={null} position={position} rotation={rotation}>
|
||||
<mesh castShadow dispose={null} position={position} receiveShadow rotation={rotation}>
|
||||
<primitive attach="geometry" dispose={null} object={geometry} />
|
||||
<ColumnMaterial />
|
||||
</mesh>
|
||||
@@ -797,7 +810,7 @@ function MappedCone({
|
||||
if (!geometry) return null
|
||||
|
||||
return (
|
||||
<mesh dispose={null} position={position} rotation={rotation}>
|
||||
<mesh castShadow dispose={null} position={position} receiveShadow rotation={rotation}>
|
||||
<primitive attach="geometry" dispose={null} object={geometry} />
|
||||
<ColumnMaterial />
|
||||
</mesh>
|
||||
@@ -823,7 +836,7 @@ function MappedSphere({
|
||||
if (!geometry) return null
|
||||
|
||||
return (
|
||||
<mesh dispose={null} position={position}>
|
||||
<mesh castShadow dispose={null} position={position} receiveShadow>
|
||||
<primitive attach="geometry" dispose={null} object={geometry} />
|
||||
<ColumnMaterial />
|
||||
</mesh>
|
||||
@@ -864,7 +877,7 @@ function MappedTorus({
|
||||
if (!geometry) return null
|
||||
|
||||
return (
|
||||
<mesh dispose={null} position={position} rotation={rotation}>
|
||||
<mesh castShadow dispose={null} position={position} receiveShadow rotation={rotation}>
|
||||
<primitive attach="geometry" dispose={null} object={geometry} />
|
||||
<ColumnMaterial />
|
||||
</mesh>
|
||||
@@ -2062,13 +2075,22 @@ export const ColumnRenderer = ({ node }: { node: ColumnNode }) => {
|
||||
const ref = useRef<Group>(null!)
|
||||
const handlers = useNodeEvents(node, 'column')
|
||||
const liveTransform = useLiveTransforms((state) => state.get(node.id))
|
||||
const shading = useViewer((state) => state.shading)
|
||||
const textures = useViewer((state) => state.textures)
|
||||
const colorPreset = useViewer((state) => state.colorPreset)
|
||||
const material = useMemo(
|
||||
() =>
|
||||
createColumnMaterial({
|
||||
material: node.material,
|
||||
materialPreset: node.materialPreset,
|
||||
shading,
|
||||
textures,
|
||||
colorPreset,
|
||||
}),
|
||||
[
|
||||
shading,
|
||||
textures,
|
||||
colorPreset,
|
||||
node.material,
|
||||
node.material?.preset,
|
||||
node.material?.properties,
|
||||
|
||||
@@ -28,6 +28,7 @@ export const doorDefinition: NodeDefinition<typeof DoorNode> = {
|
||||
schemaVersion: 1,
|
||||
schema: DoorNode,
|
||||
category: 'structure',
|
||||
surfaceRole: 'joinery',
|
||||
|
||||
// Leverage the schema's zod `.default()` annotations to compute the
|
||||
// full default shape — door has 40+ fields, listing them inline would
|
||||
|
||||
@@ -20,10 +20,7 @@ import {
|
||||
SUBTRACTION,
|
||||
} from '@pascal-app/viewer'
|
||||
import * as THREE from 'three'
|
||||
import {
|
||||
mergeGeometries,
|
||||
mergeVertices,
|
||||
} from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
||||
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
||||
|
||||
// Legacy default for the hung-wall (skirt) height. Used as a fallback
|
||||
// when `dormer.wallSkirtHeight` is undefined (e.g. old saved scenes).
|
||||
@@ -586,19 +583,39 @@ export function buildDormerCutShape(
|
||||
|
||||
const positions = new Float32Array([
|
||||
// 0..3 = bottom rect (y = -skirt) — NW, NE, SE, SW
|
||||
-hw, -skirt, -hd,
|
||||
hw, -skirt, -hd,
|
||||
hw, -skirt, hd,
|
||||
-hw, -skirt, hd,
|
||||
-hw,
|
||||
-skirt,
|
||||
-hd,
|
||||
hw,
|
||||
-skirt,
|
||||
-hd,
|
||||
hw,
|
||||
-skirt,
|
||||
hd,
|
||||
-hw,
|
||||
-skirt,
|
||||
hd,
|
||||
// 4..7 = eave rect (y = wallH) — NW, NE, SE, SW
|
||||
-hw, wallH, -hd,
|
||||
hw, wallH, -hd,
|
||||
hw, wallH, hd,
|
||||
-hw, wallH, hd,
|
||||
-hw,
|
||||
wallH,
|
||||
-hd,
|
||||
hw,
|
||||
wallH,
|
||||
-hd,
|
||||
hw,
|
||||
wallH,
|
||||
hd,
|
||||
-hw,
|
||||
wallH,
|
||||
hd,
|
||||
// 8 = ridge endpoint A (- end along the ridge axis)
|
||||
ridgeA[0], ridgeA[1], ridgeA[2],
|
||||
ridgeA[0],
|
||||
ridgeA[1],
|
||||
ridgeA[2],
|
||||
// 9 = ridge endpoint B (+ end along the ridge axis)
|
||||
ridgeB[0], ridgeB[1], ridgeB[2],
|
||||
ridgeB[0],
|
||||
ridgeB[1],
|
||||
ridgeB[2],
|
||||
])
|
||||
|
||||
// Triangles (CCW from outside). Windings verified by computing
|
||||
@@ -643,7 +660,10 @@ export function buildDormerCutShape(
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3))
|
||||
geo.setIndex(new THREE.BufferAttribute(new Uint16Array(indices), 1))
|
||||
// CSG evaluator requires 'uv'; cut brushes are never rendered so zeros are fine.
|
||||
geo.setAttribute('uv', new THREE.BufferAttribute(new Float32Array((positions.length / 3) * 2), 2))
|
||||
geo.setAttribute(
|
||||
'uv',
|
||||
new THREE.BufferAttribute(new Float32Array((positions.length / 3) * 2), 2),
|
||||
)
|
||||
geo.computeVertexNormals()
|
||||
return geo
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
DormerNode as DormerNodeSchema,
|
||||
type DormerNode as DormerNodeType,
|
||||
type NodeDefinition,
|
||||
DormerNode as DormerNodeSchema,
|
||||
} from '@pascal-app/core'
|
||||
import { buildDormerRoofCut } from './csg-geometry'
|
||||
import { dormerPaint } from './paint'
|
||||
@@ -30,6 +30,7 @@ export const dormerDefinition: NodeDefinition<typeof DormerNode> = {
|
||||
schemaVersion: 1,
|
||||
schema: DormerNode,
|
||||
category: 'structure',
|
||||
surfaceRole: 'roof',
|
||||
|
||||
defaults: () => {
|
||||
// Zod fills in id/type via their .default() factories; we strip
|
||||
|
||||
@@ -4,8 +4,8 @@ export {
|
||||
dormerSupportsArch,
|
||||
dormerSupportsCornerRadii,
|
||||
} from './geometry'
|
||||
export { DormerNode, getEffectiveDormerSurfaceMaterial } from './schema'
|
||||
export type {
|
||||
DormerSurfaceMaterialRole,
|
||||
DormerSurfaceMaterialSpec,
|
||||
} from './schema'
|
||||
export { DormerNode, getEffectiveDormerSurfaceMaterial } from './schema'
|
||||
|
||||
@@ -86,7 +86,11 @@ const MoveDormerTool = ({ node }: { node: DormerNode }) => {
|
||||
node.metadata && typeof node.metadata === 'object' && !Array.isArray(node.metadata)
|
||||
? (node.metadata as Record<string, unknown>)
|
||||
: {}
|
||||
const { isNew: _isNew, isTransient: _isTransient, ...rest } = m as {
|
||||
const {
|
||||
isNew: _isNew,
|
||||
isTransient: _isTransient,
|
||||
...rest
|
||||
} = m as {
|
||||
isNew?: boolean
|
||||
isTransient?: boolean
|
||||
}
|
||||
@@ -125,9 +129,7 @@ const MoveDormerTool = ({ node }: { node: DormerNode }) => {
|
||||
children: (prevSeg.children ?? []).filter((id) => id !== node.id),
|
||||
})
|
||||
}
|
||||
const newSeg = state.nodes[hit.segment.id as AnyNodeId] as
|
||||
| RoofSegmentNode
|
||||
| undefined
|
||||
const newSeg = state.nodes[hit.segment.id as AnyNodeId] as RoofSegmentNode | undefined
|
||||
if (newSeg && !(newSeg.children ?? []).includes(node.id)) {
|
||||
state.updateNode(hit.segment.id as AnyNodeId, {
|
||||
children: [...(newSeg.children ?? []), node.id],
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import type { DormerNode } from '@pascal-app/core'
|
||||
import {
|
||||
PanelSection,
|
||||
SegmentedControl,
|
||||
SliderControl,
|
||||
ToggleControl,
|
||||
} from '@pascal-app/editor'
|
||||
import { PanelSection, SegmentedControl, SliderControl, ToggleControl } from '@pascal-app/editor'
|
||||
import { useState } from 'react'
|
||||
|
||||
type WindowShape = DormerNode['windowShape']
|
||||
@@ -134,9 +129,12 @@ export function DormerWindowSection({
|
||||
windowShape: v as WindowShape,
|
||||
...(v === 'rounded'
|
||||
? {
|
||||
windowCornerRadii: windowCornerRadii.map((r) =>
|
||||
Math.min(r, maxRadius),
|
||||
) as [number, number, number, number],
|
||||
windowCornerRadii: windowCornerRadii.map((r) => Math.min(r, maxRadius)) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
@@ -257,12 +255,8 @@ export function DormerWindowSection({
|
||||
label="Columns"
|
||||
max={8}
|
||||
min={1}
|
||||
onChange={(v) =>
|
||||
previewProp({ windowColumns: Math.max(1, Math.min(8, Math.round(v))) })
|
||||
}
|
||||
onCommit={(v) =>
|
||||
commitProp({ windowColumns: Math.max(1, Math.min(8, Math.round(v))) })
|
||||
}
|
||||
onChange={(v) => previewProp({ windowColumns: Math.max(1, Math.min(8, Math.round(v))) })}
|
||||
onCommit={(v) => commitProp({ windowColumns: Math.max(1, Math.min(8, Math.round(v))) })}
|
||||
precision={0}
|
||||
restoreOnCommit={false}
|
||||
step={1}
|
||||
|
||||
@@ -55,8 +55,7 @@ export default function DormerPanel() {
|
||||
const overrides = useLiveNodeOverrides((s) =>
|
||||
selectedId ? (s.get(selectedId as AnyNodeId) as Partial<DormerNode> | undefined) : undefined,
|
||||
)
|
||||
const node =
|
||||
storeNode && overrides ? ({ ...storeNode, ...overrides } as DormerNode) : storeNode
|
||||
const node = storeNode && overrides ? ({ ...storeNode, ...overrides } as DormerNode) : storeNode
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(updates: Partial<DormerNode>) => {
|
||||
|
||||
@@ -30,9 +30,7 @@ export const dormerParametrics: ParametricDescriptor<DormerNode> = {
|
||||
},
|
||||
{
|
||||
label: 'Hung wall',
|
||||
fields: [
|
||||
{ key: 'wallSkirtHeight', kind: 'number', unit: 'm', min: 0.2, max: 6, step: 0.05 },
|
||||
],
|
||||
fields: [{ key: 'wallSkirtHeight', kind: 'number', unit: 'm', min: 0.2, max: 6, step: 0.05 }],
|
||||
},
|
||||
{
|
||||
label: 'Window opening',
|
||||
@@ -53,9 +51,23 @@ export const dormerParametrics: ParametricDescriptor<DormerNode> = {
|
||||
{
|
||||
label: 'Window frame',
|
||||
fields: [
|
||||
{ key: 'windowFrameThickness', kind: 'number', unit: 'm', min: 0.01, max: 0.15, step: 0.005 },
|
||||
{
|
||||
key: 'windowFrameThickness',
|
||||
kind: 'number',
|
||||
unit: 'm',
|
||||
min: 0.01,
|
||||
max: 0.15,
|
||||
step: 0.005,
|
||||
},
|
||||
{ key: 'windowFrameDepth', kind: 'number', unit: 'm', min: 0.02, max: 0.15, step: 0.005 },
|
||||
{ key: 'windowDividerThickness', kind: 'number', unit: 'm', min: 0, max: 0.06, step: 0.002 },
|
||||
{
|
||||
key: 'windowDividerThickness',
|
||||
kind: 'number',
|
||||
unit: 'm',
|
||||
min: 0,
|
||||
max: 0.06,
|
||||
step: 0.002,
|
||||
},
|
||||
{
|
||||
key: 'windowShape',
|
||||
kind: 'enum',
|
||||
|
||||
@@ -10,56 +10,22 @@ import {
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
type ColorPreset,
|
||||
createMaterial,
|
||||
createMaterialFromPresetRef,
|
||||
createSurfaceRoleMaterial,
|
||||
useNodeEvents,
|
||||
useViewer,
|
||||
} from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import type * as THREE from 'three'
|
||||
import {
|
||||
buildDormerFallbackGeometry,
|
||||
DORMER_GABLE_MATERIAL_INDEX,
|
||||
generateDormerGeometry,
|
||||
} from './csg-geometry'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import DormerWindowAssembly from './window-assembly'
|
||||
|
||||
// Three distinct default materials: wall, side, roof top.
|
||||
// All three use FrontSide — chimney / skylight do the same, and
|
||||
// DoubleSide on a MeshStandardMaterial inside the MRT scene pass
|
||||
// generates a WebGPU pipeline whose fragment stage doesn't always
|
||||
// declare an output for every MRT target, which the validator rejects
|
||||
// with "target has no corresponding fragment stage output but
|
||||
// writeMask is not zero".
|
||||
const defaultWallMat = new THREE.MeshStandardMaterial({
|
||||
color: 0xff_ff_ff,
|
||||
roughness: 0.9,
|
||||
side: THREE.FrontSide,
|
||||
})
|
||||
const defaultSideMat = new THREE.MeshStandardMaterial({
|
||||
color: 0xff_ff_ff,
|
||||
roughness: 0.9,
|
||||
side: THREE.FrontSide,
|
||||
})
|
||||
const defaultRoofMat = new THREE.MeshStandardMaterial({
|
||||
color: 0xff_ff_ff,
|
||||
roughness: 0.85,
|
||||
side: THREE.FrontSide,
|
||||
})
|
||||
|
||||
// Geometry slots produced by `generateDormerGeometry`:
|
||||
// 0 = Wall → wall material
|
||||
// 1 = Deck (side) → side material
|
||||
// 2 = Interior → wall material
|
||||
// 3 = Roof shingle → roof material
|
||||
// 4 = Gable wall → wall material (DORMER_GABLE_MATERIAL_INDEX)
|
||||
const defaultDormerMaterials: THREE.Material[] = [
|
||||
defaultWallMat,
|
||||
defaultSideMat,
|
||||
defaultWallMat,
|
||||
defaultRoofMat,
|
||||
defaultWallMat,
|
||||
]
|
||||
|
||||
const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => {
|
||||
const ref = useRef<THREE.Group>(null!)
|
||||
useRegistry(storeNode.id, 'dormer', ref)
|
||||
@@ -74,8 +40,7 @@ const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => {
|
||||
const liveOverrides = useLiveNodeOverrides((state) => state.get(storeNode.id as AnyNodeId))
|
||||
const isLiveDrag = !!liveOverrides && Object.keys(liveOverrides).length > 0
|
||||
const node = useMemo(
|
||||
() =>
|
||||
liveOverrides ? ({ ...storeNode, ...liveOverrides } as DormerNode) : storeNode,
|
||||
() => (liveOverrides ? ({ ...storeNode, ...liveOverrides } as DormerNode) : storeNode),
|
||||
[storeNode, liveOverrides],
|
||||
)
|
||||
|
||||
@@ -85,28 +50,43 @@ const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => {
|
||||
: undefined,
|
||||
)
|
||||
|
||||
const resolvedMaterials = useMemo(() => {
|
||||
const shading = useViewer((s) => s.shading)
|
||||
const textures = useViewer((s) => s.textures)
|
||||
const colorPreset: ColorPreset = useViewer((s) => s.colorPreset)
|
||||
const sceneTheme = useViewer((s) => s.sceneTheme)
|
||||
|
||||
// Geometry material slots: 0=Wall, 1=Deck/side, 2=Interior, 3=Roof
|
||||
// shingle, 4=Gable wall. Walls take the 'wall' role, the deck side and
|
||||
// shingle take 'roof'. When textures are off, every slot snaps to its
|
||||
// role colour regardless of explicit paint (the render-modes invariant).
|
||||
const material = useMemo(() => {
|
||||
const wallRole = () => createSurfaceRoleMaterial('wall', colorPreset, undefined, sceneTheme)
|
||||
const roofRole = () => createSurfaceRoleMaterial('roof', colorPreset, undefined, sceneTheme)
|
||||
|
||||
const top = getEffectiveDormerSurfaceMaterial(node, 'top')
|
||||
const side = getEffectiveDormerSurfaceMaterial(node, 'side')
|
||||
const wall = getEffectiveDormerSurfaceMaterial(node, 'wall')
|
||||
|
||||
const resolve = (spec: { material?: DormerNode['material']; materialPreset?: string }) => {
|
||||
if (spec.materialPreset) return createMaterialFromPresetRef(spec.materialPreset)
|
||||
if (spec.material) return createMaterial(spec.material)
|
||||
return null
|
||||
const resolve = (
|
||||
spec: { material?: DormerNode['material']; materialPreset?: string },
|
||||
role: () => THREE.Material,
|
||||
) => {
|
||||
if (!textures) return role()
|
||||
if (spec.materialPreset)
|
||||
return createMaterialFromPresetRef(spec.materialPreset, shading) ?? role()
|
||||
if (spec.material) return createMaterial(spec.material, shading)
|
||||
return role()
|
||||
}
|
||||
|
||||
const topMat = resolve(top)
|
||||
const sideMat = resolve(side)
|
||||
const wallMat = resolve(wall)
|
||||
|
||||
if (!(topMat || sideMat || wallMat)) return null
|
||||
|
||||
const w = wallMat ?? defaultWallMat
|
||||
const s = sideMat ?? defaultSideMat
|
||||
const t = topMat ?? defaultRoofMat
|
||||
const w = resolve(wall, wallRole)
|
||||
const s = resolve(side, roofRole)
|
||||
const t = resolve(top, roofRole)
|
||||
return [w, s, w, t, w] as THREE.Material[]
|
||||
}, [
|
||||
textures,
|
||||
colorPreset,
|
||||
sceneTheme,
|
||||
shading,
|
||||
node.material,
|
||||
node.materialPreset,
|
||||
node.topMaterial,
|
||||
@@ -117,42 +97,50 @@ const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => {
|
||||
node.wallMaterialPreset,
|
||||
])
|
||||
|
||||
const material = resolvedMaterials ?? defaultDormerMaterials
|
||||
const frameSideMat = resolvedMaterials ? resolvedMaterials[1]! : defaultSideMat
|
||||
// The window frame bars / sill take the 'joinery' role when untextured;
|
||||
// otherwise the deck-side material (slot 1) drives the frame look.
|
||||
const frameSideMat = useMemo(() => {
|
||||
if (!textures) return createSurfaceRoleMaterial('joinery', colorPreset, undefined, sceneTheme)
|
||||
return material[1]!
|
||||
}, [textures, colorPreset, sceneTheme, material])
|
||||
|
||||
const geometry = useMemo(
|
||||
() => {
|
||||
if (!segment) return null
|
||||
if (isLiveDrag) return buildDormerFallbackGeometry(node)
|
||||
return generateDormerGeometry(node, segment)
|
||||
},
|
||||
[
|
||||
isLiveDrag,
|
||||
segment,
|
||||
node.id,
|
||||
node.roofType,
|
||||
node.width,
|
||||
node.depth,
|
||||
node.height,
|
||||
node.roofHeight,
|
||||
node.wallSkirtHeight,
|
||||
node.position[0],
|
||||
node.position[1],
|
||||
node.position[2],
|
||||
node.rotation,
|
||||
node.windowWidth,
|
||||
node.windowHeight,
|
||||
node.windowOffsetX,
|
||||
node.windowOffsetY,
|
||||
node.windowShape,
|
||||
node.windowArchHeight,
|
||||
node.windowCornerRadii[0],
|
||||
node.windowCornerRadii[1],
|
||||
node.windowCornerRadii[2],
|
||||
node.windowCornerRadii[3],
|
||||
],
|
||||
// Dormer window glass has no per-node material — it always takes the
|
||||
// themed 'glazing' role (semi-transparent) in both texture modes.
|
||||
const glassMat = useMemo(
|
||||
() => createSurfaceRoleMaterial('glazing', colorPreset, undefined, sceneTheme),
|
||||
[colorPreset, sceneTheme],
|
||||
)
|
||||
|
||||
const geometry = useMemo(() => {
|
||||
if (!segment) return null
|
||||
if (isLiveDrag) return buildDormerFallbackGeometry(node)
|
||||
return generateDormerGeometry(node, segment)
|
||||
}, [
|
||||
isLiveDrag,
|
||||
segment,
|
||||
node.id,
|
||||
node.roofType,
|
||||
node.width,
|
||||
node.depth,
|
||||
node.height,
|
||||
node.roofHeight,
|
||||
node.wallSkirtHeight,
|
||||
node.position[0],
|
||||
node.position[1],
|
||||
node.position[2],
|
||||
node.rotation,
|
||||
node.windowWidth,
|
||||
node.windowHeight,
|
||||
node.windowOffsetX,
|
||||
node.windowOffsetY,
|
||||
node.windowShape,
|
||||
node.windowArchHeight,
|
||||
node.windowCornerRadii[0],
|
||||
node.windowCornerRadii[1],
|
||||
node.windowCornerRadii[2],
|
||||
node.windowCornerRadii[3],
|
||||
])
|
||||
|
||||
useEffect(() => () => geometry?.dispose(), [geometry])
|
||||
|
||||
if (!(segment && geometry)) return null
|
||||
@@ -171,9 +159,7 @@ const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => {
|
||||
rotation-y={segment.rotation ?? 0}
|
||||
visible={node.visible}
|
||||
>
|
||||
<group
|
||||
position={[node.position[0] ?? 0, node.position[1] ?? 0, node.position[2] ?? 0]}
|
||||
>
|
||||
<group position={[node.position[0] ?? 0, node.position[1] ?? 0, node.position[2] ?? 0]}>
|
||||
<group rotation-y={node.rotation ?? 0} {...handlers}>
|
||||
<mesh
|
||||
castShadow
|
||||
@@ -184,6 +170,7 @@ const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => {
|
||||
/>
|
||||
<DormerWindowAssembly
|
||||
frameMaterial={frameSideMat}
|
||||
glassMaterial={glassMat}
|
||||
node={node}
|
||||
segment={segment}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export { DormerNode, getEffectiveDormerSurfaceMaterial } from '@pascal-app/core'
|
||||
export type {
|
||||
DormerSurfaceMaterialRole,
|
||||
DormerSurfaceMaterialSpec,
|
||||
} from '@pascal-app/core'
|
||||
export { DormerNode, getEffectiveDormerSurfaceMaterial } from '@pascal-app/core'
|
||||
|
||||
@@ -11,10 +11,7 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { resolveRoofSegmentHit } from '../roof/segment-hit'
|
||||
import {
|
||||
DORMER_PLACEMENT_ROTATION_STEP,
|
||||
DORMER_PLACEMENT_SNAP_M,
|
||||
} from './geometry'
|
||||
import { DORMER_PLACEMENT_ROTATION_STEP, DORMER_PLACEMENT_SNAP_M } from './geometry'
|
||||
|
||||
const tmpMatrix = new THREE.Matrix4()
|
||||
const tmpInv = new THREE.Matrix4()
|
||||
@@ -138,9 +135,7 @@ export function useDormerPlacement(opts: {
|
||||
const target = e.target as HTMLElement | null
|
||||
if (
|
||||
target &&
|
||||
(target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.isContentEditable)
|
||||
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
|
||||
)
|
||||
return
|
||||
const dir = e.shiftKey ? -1 : 1
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import type { DormerNode, RoofSegmentNode } from '@pascal-app/core'
|
||||
import { glassMaterial } from '@pascal-app/viewer'
|
||||
import { getDormerExposedFaces, getDormerSkirtWindowDims } from './csg-geometry'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { getDormerExposedFaces, getDormerSkirtWindowDims } from './csg-geometry'
|
||||
import { buildDormerWindowGeometries, type DormerWindowShape } from './window-frame'
|
||||
|
||||
/**
|
||||
@@ -22,10 +21,12 @@ const DormerWindowAssembly = ({
|
||||
node,
|
||||
segment,
|
||||
frameMaterial,
|
||||
glassMaterial,
|
||||
}: {
|
||||
node: DormerNode
|
||||
segment: RoofSegmentNode
|
||||
frameMaterial: THREE.Material
|
||||
glassMaterial: THREE.Material
|
||||
}) => {
|
||||
const skirtWin = useMemo(
|
||||
() => getDormerSkirtWindowDims(node),
|
||||
|
||||
@@ -54,7 +54,11 @@ export function buildDormerWindowGeometries(
|
||||
|
||||
const innerHole =
|
||||
shape === 'arch'
|
||||
? createDormerArchShape(winW - 2 * safeFt, winH - 2 * safeFt, Math.max(archHeight - safeFt, 0.01))
|
||||
? createDormerArchShape(
|
||||
winW - 2 * safeFt,
|
||||
winH - 2 * safeFt,
|
||||
Math.max(archHeight - safeFt, 0.01),
|
||||
)
|
||||
: createDormerRoundedShape(winW - 2 * safeFt, winH - 2 * safeFt, insetRadii)
|
||||
|
||||
outerShape.holes.push(innerHole)
|
||||
@@ -88,7 +92,11 @@ export function buildDormerWindowGeometries(
|
||||
|
||||
const glassShape =
|
||||
shape === 'arch'
|
||||
? createDormerArchShape(winW - 2 * safeFt, winH - 2 * safeFt, Math.max(archHeight - safeFt, 0.01))
|
||||
? createDormerArchShape(
|
||||
winW - 2 * safeFt,
|
||||
winH - 2 * safeFt,
|
||||
Math.max(archHeight - safeFt, 0.01),
|
||||
)
|
||||
: createDormerRoundedShape(winW - 2 * safeFt, winH - 2 * safeFt, insetRadii)
|
||||
const glassGeo = new THREE.ExtrudeGeometry(glassShape, {
|
||||
depth: 0.008,
|
||||
|
||||
@@ -15,6 +15,7 @@ export const elevatorDefinition: NodeDefinition<typeof ElevatorNode> = {
|
||||
schemaVersion: 1,
|
||||
schema: ElevatorNode,
|
||||
category: 'structure',
|
||||
surfaceRole: 'joinery',
|
||||
|
||||
defaults: () => {
|
||||
const stub = ElevatorNodeSchema.parse({ id: 'elevator_default' as never, type: 'elevator' })
|
||||
|
||||
@@ -23,7 +23,14 @@ import {
|
||||
useRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useNodeEvents } from '@pascal-app/viewer'
|
||||
import {
|
||||
type ColorPreset,
|
||||
createDefaultMaterial,
|
||||
createSurfaceRoleMaterial,
|
||||
type RenderShading,
|
||||
useNodeEvents,
|
||||
useViewer,
|
||||
} from '@pascal-app/viewer'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import { useCallback, useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import {
|
||||
@@ -31,7 +38,7 @@ import {
|
||||
CylinderGeometry,
|
||||
type Group,
|
||||
type InstancedMesh,
|
||||
MeshStandardMaterial,
|
||||
type Material,
|
||||
Object3D,
|
||||
TorusGeometry,
|
||||
} from 'three'
|
||||
@@ -58,184 +65,343 @@ const SHAFT_TOP_FRAME_CLEARANCE = 0.006
|
||||
type ElevatorDoorPanelStyleValue = ElevatorNode['doorPanelStyle']
|
||||
type ElevatorDoorStyleValue = ElevatorNode['doorStyle']
|
||||
|
||||
const SHAFT_WALL_MATERIAL = new MeshStandardMaterial({
|
||||
color: SHAFT_WALL_COLOR,
|
||||
metalness: 0.08,
|
||||
roughness: 0.56,
|
||||
})
|
||||
const SHAFT_SIDE_MATERIAL = new MeshStandardMaterial({
|
||||
color: SHAFT_SIDE_COLOR,
|
||||
metalness: 0.12,
|
||||
roughness: 0.58,
|
||||
})
|
||||
const SHAFT_TRIM_MATERIAL = new MeshStandardMaterial({
|
||||
color: SHAFT_TRIM_COLOR,
|
||||
metalness: 0.2,
|
||||
roughness: 0.38,
|
||||
})
|
||||
const CAB_MATERIAL = new MeshStandardMaterial({
|
||||
color: CAB_COLOR,
|
||||
metalness: 0.2,
|
||||
roughness: 0.48,
|
||||
})
|
||||
const DOOR_MATERIAL = new MeshStandardMaterial({
|
||||
color: DOOR_COLOR,
|
||||
metalness: 0.34,
|
||||
roughness: 0.34,
|
||||
})
|
||||
const DOOR_GROOVE_MATERIAL = new MeshStandardMaterial({
|
||||
color: '#5f6978',
|
||||
metalness: 0.28,
|
||||
roughness: 0.42,
|
||||
})
|
||||
const GLASS_MATERIAL = new MeshStandardMaterial({
|
||||
color: GLASS_COLOR,
|
||||
depthWrite: false,
|
||||
metalness: 0,
|
||||
opacity: 0.2,
|
||||
roughness: 0.08,
|
||||
transparent: true,
|
||||
})
|
||||
const PANEL_MATERIAL = new MeshStandardMaterial({
|
||||
color: PANEL_COLOR,
|
||||
metalness: 0.32,
|
||||
roughness: 0.36,
|
||||
})
|
||||
const LANDING_PANEL_MATERIAL = new MeshStandardMaterial({
|
||||
color: PANEL_COLOR,
|
||||
metalness: 0.25,
|
||||
roughness: 0.4,
|
||||
})
|
||||
const INDICATOR_SCREEN_MATERIALS = {
|
||||
active: new MeshStandardMaterial({
|
||||
color: '#041f2f',
|
||||
emissive: '#0ea5e9',
|
||||
emissiveIntensity: 0.16,
|
||||
metalness: 0.12,
|
||||
roughness: 0.38,
|
||||
}),
|
||||
idle: new MeshStandardMaterial({
|
||||
color: '#111827',
|
||||
metalness: 0.12,
|
||||
roughness: 0.38,
|
||||
}),
|
||||
type ElevatorMaterial = Material & {
|
||||
depthWrite?: boolean
|
||||
emissive?: { set: (color: string) => void }
|
||||
emissiveIntensity?: number
|
||||
metalness?: number
|
||||
opacity?: number
|
||||
roughness?: number
|
||||
transparent?: boolean
|
||||
}
|
||||
const INDICATOR_GLYPH_MATERIALS = {
|
||||
active: new MeshStandardMaterial({
|
||||
color: '#38bdf8',
|
||||
emissive: '#38bdf8',
|
||||
emissiveIntensity: 0.36,
|
||||
metalness: 0.08,
|
||||
roughness: 0.32,
|
||||
}),
|
||||
idle: new MeshStandardMaterial({
|
||||
color: '#94a3b8',
|
||||
emissive: '#94a3b8',
|
||||
emissiveIntensity: 0.18,
|
||||
metalness: 0.08,
|
||||
roughness: 0.32,
|
||||
}),
|
||||
|
||||
type ElevatorMaterialParams = {
|
||||
color: string
|
||||
depthWrite?: boolean
|
||||
emissive?: string
|
||||
emissiveIntensity?: number
|
||||
metalness?: number
|
||||
opacity?: number
|
||||
roughness?: number
|
||||
transparent?: boolean
|
||||
}
|
||||
const BUTTON_FACE_MATERIALS = {
|
||||
active: new MeshStandardMaterial({
|
||||
color: '#38bdf8',
|
||||
emissive: '#38bdf8',
|
||||
emissiveIntensity: 0.28,
|
||||
metalness: 0.22,
|
||||
roughness: 0.3,
|
||||
}),
|
||||
queued: new MeshStandardMaterial({
|
||||
color: '#fbbf24',
|
||||
emissive: '#fbbf24',
|
||||
emissiveIntensity: 0.18,
|
||||
metalness: 0.22,
|
||||
roughness: 0.3,
|
||||
}),
|
||||
idle: new MeshStandardMaterial({
|
||||
color: '#d6dde7',
|
||||
metalness: 0.22,
|
||||
roughness: 0.3,
|
||||
}),
|
||||
disabled: new MeshStandardMaterial({
|
||||
color: '#475569',
|
||||
metalness: 0.12,
|
||||
roughness: 0.52,
|
||||
}),
|
||||
|
||||
function createElevatorMaterial(params: ElevatorMaterialParams, shading: RenderShading): Material {
|
||||
const material = createDefaultMaterial(
|
||||
params.color,
|
||||
params.roughness ?? 0.5,
|
||||
shading,
|
||||
) as ElevatorMaterial
|
||||
|
||||
if ('metalness' in material) material.metalness = params.metalness ?? 0
|
||||
if ('roughness' in material && params.roughness !== undefined) {
|
||||
material.roughness = params.roughness
|
||||
}
|
||||
if (params.depthWrite !== undefined) material.depthWrite = params.depthWrite
|
||||
if (params.opacity !== undefined) material.opacity = params.opacity
|
||||
if (params.transparent !== undefined) material.transparent = params.transparent
|
||||
if (params.emissive && material.emissive) material.emissive.set(params.emissive)
|
||||
if ('emissiveIntensity' in material && params.emissiveIntensity !== undefined) {
|
||||
material.emissiveIntensity = params.emissiveIntensity
|
||||
}
|
||||
material.needsUpdate = true
|
||||
return material
|
||||
}
|
||||
const BUTTON_RING_MATERIALS = {
|
||||
active: new MeshStandardMaterial({
|
||||
color: '#0ea5e9',
|
||||
emissive: '#0ea5e9',
|
||||
emissiveIntensity: 0.16,
|
||||
metalness: 0.48,
|
||||
roughness: 0.28,
|
||||
}),
|
||||
queued: new MeshStandardMaterial({
|
||||
color: '#f59e0b',
|
||||
emissive: '#f59e0b',
|
||||
emissiveIntensity: 0.1,
|
||||
metalness: 0.48,
|
||||
roughness: 0.28,
|
||||
}),
|
||||
idle: new MeshStandardMaterial({
|
||||
color: '#64748b',
|
||||
metalness: 0.48,
|
||||
roughness: 0.28,
|
||||
}),
|
||||
disabled: new MeshStandardMaterial({
|
||||
color: '#334155',
|
||||
metalness: 0.28,
|
||||
roughness: 0.5,
|
||||
}),
|
||||
|
||||
function createElevatorMaterials(
|
||||
shading: RenderShading,
|
||||
textures = true,
|
||||
colorPreset: ColorPreset = 'clay',
|
||||
) {
|
||||
if (!textures) {
|
||||
const material = createSurfaceRoleMaterial('joinery', colorPreset)
|
||||
return {
|
||||
SHAFT_WALL_MATERIAL: material,
|
||||
SHAFT_SIDE_MATERIAL: material,
|
||||
SHAFT_TRIM_MATERIAL: material,
|
||||
CAB_MATERIAL: material,
|
||||
DOOR_MATERIAL: material,
|
||||
DOOR_GROOVE_MATERIAL: material,
|
||||
GLASS_MATERIAL: material,
|
||||
PANEL_MATERIAL: material,
|
||||
LANDING_PANEL_MATERIAL: material,
|
||||
INDICATOR_SCREEN_MATERIALS: {
|
||||
active: material,
|
||||
idle: material,
|
||||
},
|
||||
INDICATOR_GLYPH_MATERIALS: {
|
||||
active: material,
|
||||
idle: material,
|
||||
},
|
||||
BUTTON_FACE_MATERIALS: {
|
||||
active: material,
|
||||
queued: material,
|
||||
idle: material,
|
||||
disabled: material,
|
||||
},
|
||||
BUTTON_RING_MATERIALS: {
|
||||
active: material,
|
||||
queued: material,
|
||||
idle: material,
|
||||
disabled: material,
|
||||
},
|
||||
BUTTON_GLOW_MATERIALS: {
|
||||
active: material,
|
||||
queued: material,
|
||||
},
|
||||
BUTTON_LABEL_MATERIALS: {
|
||||
lit: material,
|
||||
idle: material,
|
||||
disabled: material,
|
||||
},
|
||||
QUEUE_STRIP_MATERIALS: {
|
||||
queued: material,
|
||||
idle: material,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
SHAFT_WALL_MATERIAL: createElevatorMaterial(
|
||||
{ color: SHAFT_WALL_COLOR, metalness: 0.08, roughness: 0.56 },
|
||||
shading,
|
||||
),
|
||||
SHAFT_SIDE_MATERIAL: createElevatorMaterial(
|
||||
{ color: SHAFT_SIDE_COLOR, metalness: 0.12, roughness: 0.58 },
|
||||
shading,
|
||||
),
|
||||
SHAFT_TRIM_MATERIAL: createElevatorMaterial(
|
||||
{ color: SHAFT_TRIM_COLOR, metalness: 0.2, roughness: 0.38 },
|
||||
shading,
|
||||
),
|
||||
CAB_MATERIAL: createElevatorMaterial(
|
||||
{ color: CAB_COLOR, metalness: 0.2, roughness: 0.48 },
|
||||
shading,
|
||||
),
|
||||
DOOR_MATERIAL: createElevatorMaterial(
|
||||
{ color: DOOR_COLOR, metalness: 0.34, roughness: 0.34 },
|
||||
shading,
|
||||
),
|
||||
DOOR_GROOVE_MATERIAL: createElevatorMaterial(
|
||||
{ color: '#5f6978', metalness: 0.28, roughness: 0.42 },
|
||||
shading,
|
||||
),
|
||||
GLASS_MATERIAL: createElevatorMaterial(
|
||||
{
|
||||
color: GLASS_COLOR,
|
||||
depthWrite: false,
|
||||
metalness: 0,
|
||||
opacity: 0.2,
|
||||
roughness: 0.08,
|
||||
transparent: true,
|
||||
},
|
||||
shading,
|
||||
),
|
||||
PANEL_MATERIAL: createElevatorMaterial(
|
||||
{ color: PANEL_COLOR, metalness: 0.32, roughness: 0.36 },
|
||||
shading,
|
||||
),
|
||||
LANDING_PANEL_MATERIAL: createElevatorMaterial(
|
||||
{ color: PANEL_COLOR, metalness: 0.25, roughness: 0.4 },
|
||||
shading,
|
||||
),
|
||||
INDICATOR_SCREEN_MATERIALS: {
|
||||
active: createElevatorMaterial(
|
||||
{
|
||||
color: '#041f2f',
|
||||
emissive: '#0ea5e9',
|
||||
emissiveIntensity: 0.16,
|
||||
metalness: 0.12,
|
||||
roughness: 0.38,
|
||||
},
|
||||
shading,
|
||||
),
|
||||
idle: createElevatorMaterial({ color: '#111827', metalness: 0.12, roughness: 0.38 }, shading),
|
||||
},
|
||||
INDICATOR_GLYPH_MATERIALS: {
|
||||
active: createElevatorMaterial(
|
||||
{
|
||||
color: '#38bdf8',
|
||||
emissive: '#38bdf8',
|
||||
emissiveIntensity: 0.36,
|
||||
metalness: 0.08,
|
||||
roughness: 0.32,
|
||||
},
|
||||
shading,
|
||||
),
|
||||
idle: createElevatorMaterial(
|
||||
{
|
||||
color: '#94a3b8',
|
||||
emissive: '#94a3b8',
|
||||
emissiveIntensity: 0.18,
|
||||
metalness: 0.08,
|
||||
roughness: 0.32,
|
||||
},
|
||||
shading,
|
||||
),
|
||||
},
|
||||
BUTTON_FACE_MATERIALS: {
|
||||
active: createElevatorMaterial(
|
||||
{
|
||||
color: '#38bdf8',
|
||||
emissive: '#38bdf8',
|
||||
emissiveIntensity: 0.28,
|
||||
metalness: 0.22,
|
||||
roughness: 0.3,
|
||||
},
|
||||
shading,
|
||||
),
|
||||
queued: createElevatorMaterial(
|
||||
{
|
||||
color: '#fbbf24',
|
||||
emissive: '#fbbf24',
|
||||
emissiveIntensity: 0.18,
|
||||
metalness: 0.22,
|
||||
roughness: 0.3,
|
||||
},
|
||||
shading,
|
||||
),
|
||||
idle: createElevatorMaterial({ color: '#d6dde7', metalness: 0.22, roughness: 0.3 }, shading),
|
||||
disabled: createElevatorMaterial(
|
||||
{ color: '#475569', metalness: 0.12, roughness: 0.52 },
|
||||
shading,
|
||||
),
|
||||
},
|
||||
BUTTON_RING_MATERIALS: {
|
||||
active: createElevatorMaterial(
|
||||
{
|
||||
color: '#0ea5e9',
|
||||
emissive: '#0ea5e9',
|
||||
emissiveIntensity: 0.16,
|
||||
metalness: 0.48,
|
||||
roughness: 0.28,
|
||||
},
|
||||
shading,
|
||||
),
|
||||
queued: createElevatorMaterial(
|
||||
{
|
||||
color: '#f59e0b',
|
||||
emissive: '#f59e0b',
|
||||
emissiveIntensity: 0.1,
|
||||
metalness: 0.48,
|
||||
roughness: 0.28,
|
||||
},
|
||||
shading,
|
||||
),
|
||||
idle: createElevatorMaterial({ color: '#64748b', metalness: 0.48, roughness: 0.28 }, shading),
|
||||
disabled: createElevatorMaterial(
|
||||
{ color: '#334155', metalness: 0.28, roughness: 0.5 },
|
||||
shading,
|
||||
),
|
||||
},
|
||||
BUTTON_GLOW_MATERIALS: {
|
||||
active: createElevatorMaterial(
|
||||
{
|
||||
color: '#38bdf8',
|
||||
depthWrite: false,
|
||||
emissive: '#38bdf8',
|
||||
emissiveIntensity: 0.28,
|
||||
opacity: 0.58,
|
||||
transparent: true,
|
||||
},
|
||||
shading,
|
||||
),
|
||||
queued: createElevatorMaterial(
|
||||
{
|
||||
color: '#fbbf24',
|
||||
depthWrite: false,
|
||||
emissive: '#fbbf24',
|
||||
emissiveIntensity: 0.18,
|
||||
opacity: 0.58,
|
||||
transparent: true,
|
||||
},
|
||||
shading,
|
||||
),
|
||||
},
|
||||
BUTTON_LABEL_MATERIALS: {
|
||||
lit: createElevatorMaterial({ color: '#111827', metalness: 0.12, roughness: 0.34 }, shading),
|
||||
idle: createElevatorMaterial({ color: '#334155', metalness: 0.12, roughness: 0.34 }, shading),
|
||||
disabled: createElevatorMaterial(
|
||||
{ color: '#94a3b8', metalness: 0.08, roughness: 0.5 },
|
||||
shading,
|
||||
),
|
||||
},
|
||||
QUEUE_STRIP_MATERIALS: {
|
||||
queued: createElevatorMaterial(
|
||||
{
|
||||
color: '#fbbf24',
|
||||
emissive: '#fbbf24',
|
||||
emissiveIntensity: 0.16,
|
||||
metalness: 0.18,
|
||||
roughness: 0.42,
|
||||
},
|
||||
shading,
|
||||
),
|
||||
idle: createElevatorMaterial({ color: '#64748b', metalness: 0.18, roughness: 0.42 }, shading),
|
||||
},
|
||||
}
|
||||
}
|
||||
const BUTTON_GLOW_MATERIALS = {
|
||||
active: new MeshStandardMaterial({
|
||||
color: '#38bdf8',
|
||||
depthWrite: false,
|
||||
emissive: '#38bdf8',
|
||||
emissiveIntensity: 0.28,
|
||||
opacity: 0.58,
|
||||
transparent: true,
|
||||
}),
|
||||
queued: new MeshStandardMaterial({
|
||||
color: '#fbbf24',
|
||||
depthWrite: false,
|
||||
emissive: '#fbbf24',
|
||||
emissiveIntensity: 0.18,
|
||||
opacity: 0.58,
|
||||
transparent: true,
|
||||
}),
|
||||
|
||||
type ElevatorMaterialSet = ReturnType<typeof createElevatorMaterials>
|
||||
|
||||
const elevatorMaterialsCache = new Map<string, ElevatorMaterialSet>()
|
||||
|
||||
function getElevatorMaterials(
|
||||
shading: RenderShading,
|
||||
textures = true,
|
||||
colorPreset: ColorPreset = 'clay',
|
||||
): ElevatorMaterialSet {
|
||||
const cacheKey = `${shading}-${textures}-${colorPreset}`
|
||||
const cached = elevatorMaterialsCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const materials = createElevatorMaterials(shading, textures, colorPreset)
|
||||
elevatorMaterialsCache.set(cacheKey, materials)
|
||||
return materials
|
||||
}
|
||||
const BUTTON_LABEL_MATERIALS = {
|
||||
lit: new MeshStandardMaterial({
|
||||
color: '#111827',
|
||||
metalness: 0.12,
|
||||
roughness: 0.34,
|
||||
}),
|
||||
idle: new MeshStandardMaterial({
|
||||
color: '#334155',
|
||||
metalness: 0.12,
|
||||
roughness: 0.34,
|
||||
}),
|
||||
disabled: new MeshStandardMaterial({
|
||||
color: '#94a3b8',
|
||||
metalness: 0.08,
|
||||
roughness: 0.5,
|
||||
}),
|
||||
}
|
||||
const QUEUE_STRIP_MATERIALS = {
|
||||
queued: new MeshStandardMaterial({
|
||||
color: '#fbbf24',
|
||||
emissive: '#fbbf24',
|
||||
emissiveIntensity: 0.16,
|
||||
metalness: 0.18,
|
||||
roughness: 0.42,
|
||||
}),
|
||||
idle: new MeshStandardMaterial({
|
||||
color: '#64748b',
|
||||
metalness: 0.18,
|
||||
roughness: 0.42,
|
||||
}),
|
||||
|
||||
let {
|
||||
SHAFT_WALL_MATERIAL,
|
||||
SHAFT_SIDE_MATERIAL,
|
||||
SHAFT_TRIM_MATERIAL,
|
||||
CAB_MATERIAL,
|
||||
DOOR_MATERIAL,
|
||||
DOOR_GROOVE_MATERIAL,
|
||||
GLASS_MATERIAL,
|
||||
PANEL_MATERIAL,
|
||||
LANDING_PANEL_MATERIAL,
|
||||
INDICATOR_SCREEN_MATERIALS,
|
||||
INDICATOR_GLYPH_MATERIALS,
|
||||
BUTTON_FACE_MATERIALS,
|
||||
BUTTON_RING_MATERIALS,
|
||||
BUTTON_GLOW_MATERIALS,
|
||||
BUTTON_LABEL_MATERIALS,
|
||||
QUEUE_STRIP_MATERIALS,
|
||||
} = getElevatorMaterials('rendered')
|
||||
|
||||
function setElevatorMaterials(
|
||||
shading: RenderShading,
|
||||
textures = true,
|
||||
colorPreset: ColorPreset = 'clay',
|
||||
) {
|
||||
;({
|
||||
SHAFT_WALL_MATERIAL,
|
||||
SHAFT_SIDE_MATERIAL,
|
||||
SHAFT_TRIM_MATERIAL,
|
||||
CAB_MATERIAL,
|
||||
DOOR_MATERIAL,
|
||||
DOOR_GROOVE_MATERIAL,
|
||||
GLASS_MATERIAL,
|
||||
PANEL_MATERIAL,
|
||||
LANDING_PANEL_MATERIAL,
|
||||
INDICATOR_SCREEN_MATERIALS,
|
||||
INDICATOR_GLYPH_MATERIALS,
|
||||
BUTTON_FACE_MATERIALS,
|
||||
BUTTON_RING_MATERIALS,
|
||||
BUTTON_GLOW_MATERIALS,
|
||||
BUTTON_LABEL_MATERIALS,
|
||||
QUEUE_STRIP_MATERIALS,
|
||||
} = getElevatorMaterials(shading, textures, colorPreset))
|
||||
}
|
||||
|
||||
type ElevatorButtonAction = 'open-door' | 'request-level'
|
||||
@@ -285,7 +451,7 @@ function BoxPrimitive({
|
||||
scale,
|
||||
}: {
|
||||
castShadow?: boolean
|
||||
material: MeshStandardMaterial
|
||||
material: Material
|
||||
position?: Vector3Tuple
|
||||
receiveShadow?: boolean
|
||||
rotation?: Vector3Tuple
|
||||
@@ -314,7 +480,7 @@ function MeshButtonLabel({
|
||||
}: {
|
||||
faceSign?: -1 | 1
|
||||
label: string
|
||||
material: MeshStandardMaterial
|
||||
material: Material
|
||||
position: [number, number, number]
|
||||
scale: number
|
||||
}) {
|
||||
@@ -381,7 +547,7 @@ function ElevatorDirectionGlyph({
|
||||
scale,
|
||||
}: {
|
||||
direction: 'down' | 'up' | null
|
||||
material: MeshStandardMaterial
|
||||
material: Material
|
||||
position: [number, number, number]
|
||||
scale: number
|
||||
}) {
|
||||
@@ -484,7 +650,7 @@ function DoorOpenGlyph({
|
||||
positionZ,
|
||||
scale,
|
||||
}: {
|
||||
material: MeshStandardMaterial
|
||||
material: Material
|
||||
positionZ: number
|
||||
scale: number
|
||||
}) {
|
||||
@@ -950,6 +1116,9 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => {
|
||||
const ref = useRef<Group>(null!)
|
||||
const cabRef = useRef<Group>(null)
|
||||
const handlers = useNodeEvents(node, 'elevator')
|
||||
const shading = useViewer((state) => state.shading)
|
||||
const textures = useViewer((state) => state.textures)
|
||||
const colorPreset = useViewer((state) => state.colorPreset)
|
||||
const liveOverrides = useLiveNodeOverrides((state) => state.get(node.id))
|
||||
const liveTransform = useLiveTransforms((state) => state.get(node.id))
|
||||
const renderNode = useMemo(
|
||||
@@ -960,6 +1129,8 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => {
|
||||
useShallow((state) => getElevatorLevelContextNodes(renderNode, state.nodes)),
|
||||
)
|
||||
|
||||
setElevatorMaterials(shading, textures, colorPreset)
|
||||
|
||||
useRegistry(node.id, 'elevator', ref)
|
||||
|
||||
const { entries, defaultEntry, shaftBaseY, totalHeight } = useMemo(
|
||||
|
||||
@@ -23,6 +23,7 @@ export const fenceDefinition: NodeDefinition<typeof FenceNode> = {
|
||||
schemaVersion: 1,
|
||||
schema: FenceNode,
|
||||
category: 'structure',
|
||||
surfaceRole: 'wall',
|
||||
|
||||
defaults: () => ({
|
||||
object: 'node',
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { DEFAULT_STAIR_MATERIAL, generateFenceGeometry } from '@pascal-app/viewer'
|
||||
import {
|
||||
DEFAULT_STAIR_MATERIAL,
|
||||
generateFenceGeometry,
|
||||
type RenderShading,
|
||||
} from '@pascal-app/viewer'
|
||||
import { Group, Mesh } from 'three'
|
||||
import type { FenceNode } from './schema'
|
||||
|
||||
@@ -18,10 +22,14 @@ import type { FenceNode } from './schema'
|
||||
* once the legacy system file is deleted. Until then `generateFenceGeometry`
|
||||
* is publicly re-exported from viewer.
|
||||
*/
|
||||
export function buildFenceGeometry(node: FenceNode): Group {
|
||||
export function buildFenceGeometry(
|
||||
node: FenceNode,
|
||||
_ctx?: unknown,
|
||||
shading: RenderShading = 'rendered',
|
||||
): Group {
|
||||
const group = new Group()
|
||||
const geometry = generateFenceGeometry(node)
|
||||
const mesh = new Mesh(geometry, DEFAULT_STAIR_MATERIAL)
|
||||
const mesh = new Mesh(geometry, DEFAULT_STAIR_MATERIAL(shading))
|
||||
mesh.castShadow = true
|
||||
mesh.receiveShadow = true
|
||||
group.add(mesh)
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
snapFenceDraftPoint,
|
||||
triggerSFX,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { getSceneTheme, useViewer } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BoxGeometry, BufferGeometry, DoubleSide, type Group, type Mesh, Vector3 } from 'three'
|
||||
@@ -413,7 +413,7 @@ function getCurrentLevelElements(): { walls: WallNode[]; fences: FenceNode[] } {
|
||||
|
||||
export const FenceTool: React.FC = () => {
|
||||
const unit = useViewer((state) => state.unit)
|
||||
const theme = useViewer((state) => state.theme)
|
||||
const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
|
||||
const cursorRef = useRef<Group>(null)
|
||||
const previewRef = useRef<Mesh>(null!)
|
||||
const startingPoint = useRef(new Vector3(0, 0, 0))
|
||||
@@ -421,8 +421,8 @@ export const FenceTool: React.FC = () => {
|
||||
const buildingState = useRef(0)
|
||||
const shiftPressed = useRef(false)
|
||||
const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)
|
||||
const measurementColor = theme === 'dark' ? '#ffffff' : '#111111'
|
||||
const measurementShadowColor = theme === 'dark' ? '#111111' : '#ffffff'
|
||||
const measurementColor = isDark ? '#ffffff' : '#111111'
|
||||
const measurementShadowColor = isDark ? '#111111' : '#ffffff'
|
||||
|
||||
useEffect(() => {
|
||||
let previousFenceEnd: FencePlanPoint | null = null
|
||||
|
||||
+10
-10
@@ -1,25 +1,25 @@
|
||||
import type { AnyNodeDefinition, Plugin } from '@pascal-app/core'
|
||||
import { boxVentDefinition } from './box-vent'
|
||||
import { buildingDefinition } from './building'
|
||||
import { chimneyDefinition } from './chimney'
|
||||
import { dormerDefinition } from './dormer'
|
||||
import { ridgeVentDefinition } from './ridge-vent'
|
||||
import { skylightDefinition } from './skylight'
|
||||
import { solarPanelDefinition } from './solar-panel'
|
||||
import { ceilingDefinition } from './ceiling'
|
||||
import { chimneyDefinition } from './chimney'
|
||||
import { columnDefinition } from './column'
|
||||
import { doorDefinition } from './door'
|
||||
import { dormerDefinition } from './dormer'
|
||||
import { elevatorDefinition } from './elevator'
|
||||
import { fenceDefinition } from './fence'
|
||||
import { guideDefinition } from './guide'
|
||||
import { itemDefinition } from './item'
|
||||
import { levelDefinition } from './level'
|
||||
import { ridgeVentDefinition } from './ridge-vent'
|
||||
import { roofDefinition } from './roof'
|
||||
import { roofSegmentDefinition } from './roof-segment'
|
||||
import { scanDefinition } from './scan'
|
||||
import { shelfDefinition } from './shelf'
|
||||
import { siteDefinition } from './site'
|
||||
import { skylightDefinition } from './skylight'
|
||||
import { slabDefinition } from './slab'
|
||||
import { solarPanelDefinition } from './solar-panel'
|
||||
import { spawnDefinition } from './spawn'
|
||||
import { stairDefinition } from './stair'
|
||||
import { stairSegmentDefinition } from './stair-segment'
|
||||
@@ -83,25 +83,25 @@ export const builtinPlugin: Plugin = {
|
||||
|
||||
export { boxVentDefinition } from './box-vent'
|
||||
export { buildingDefinition } from './building'
|
||||
export { chimneyDefinition } from './chimney'
|
||||
export { dormerDefinition } from './dormer'
|
||||
export { ridgeVentDefinition } from './ridge-vent'
|
||||
export { skylightDefinition } from './skylight'
|
||||
export { solarPanelDefinition } from './solar-panel'
|
||||
export { ceilingDefinition } from './ceiling'
|
||||
export { chimneyDefinition } from './chimney'
|
||||
export { columnDefinition } from './column'
|
||||
export { doorDefinition } from './door'
|
||||
export { dormerDefinition } from './dormer'
|
||||
export { elevatorDefinition } from './elevator'
|
||||
export { fenceDefinition } from './fence'
|
||||
export { guideDefinition } from './guide'
|
||||
export { itemDefinition } from './item'
|
||||
export { levelDefinition } from './level'
|
||||
export { ridgeVentDefinition } from './ridge-vent'
|
||||
export { roofDefinition } from './roof'
|
||||
export { roofSegmentDefinition } from './roof-segment'
|
||||
export { scanDefinition } from './scan'
|
||||
export { shelfDefinition } from './shelf'
|
||||
export { siteDefinition } from './site'
|
||||
export { skylightDefinition } from './skylight'
|
||||
export { slabDefinition } from './slab'
|
||||
export { solarPanelDefinition } from './solar-panel'
|
||||
export { spawnDefinition } from './spawn'
|
||||
export { stairDefinition } from './stair'
|
||||
export { stairSegmentDefinition } from './stair-segment'
|
||||
|
||||
@@ -41,6 +41,7 @@ export const itemDefinition: NodeDefinition<typeof ItemNode> = {
|
||||
schemaVersion: 1,
|
||||
schema: ItemNode,
|
||||
category: 'furnish',
|
||||
surfaceRole: 'furnishing',
|
||||
|
||||
// Defaults shape is cast: the schema requires a fully-typed `asset`
|
||||
// field, but in practice items are always created from the catalog
|
||||
|
||||
@@ -12,12 +12,17 @@ import {
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
baseMaterial,
|
||||
type ColorPreset,
|
||||
createDefaultMaterial,
|
||||
createSurfaceRoleMaterial,
|
||||
ErrorBoundary,
|
||||
glassMaterial,
|
||||
NodeRenderer,
|
||||
type RenderShading,
|
||||
resolveCdnUrl,
|
||||
useItemLightPool,
|
||||
useNodeEvents,
|
||||
useViewer,
|
||||
} from '@pascal-app/viewer'
|
||||
import { useAnimations } from '@react-three/drei'
|
||||
import { Clone } from '@react-three/drei/core/Clone'
|
||||
@@ -27,22 +32,45 @@ import { Suspense, useEffect, useMemo, useRef } from 'react'
|
||||
import type { AnimationAction, Group, Material, Mesh } from 'three'
|
||||
import { MathUtils } from 'three'
|
||||
import { positionLocal, smoothstep, time } from 'three/tsl'
|
||||
import { MeshStandardNodeMaterial } from 'three/webgpu'
|
||||
|
||||
const getMaterialForOriginal = (original: Material): Material => {
|
||||
type MutableMaterial = Material & {
|
||||
depthTest?: boolean
|
||||
opacity?: number
|
||||
opacityNode?: unknown
|
||||
transparent?: boolean
|
||||
wireframe?: boolean
|
||||
}
|
||||
|
||||
const getMaterialForOriginal = (
|
||||
original: Material,
|
||||
shading: RenderShading,
|
||||
textures: boolean,
|
||||
colorPreset: ColorPreset,
|
||||
): Material => {
|
||||
if (original.name.toLowerCase() === 'glass') {
|
||||
return glassMaterial
|
||||
}
|
||||
return baseMaterial
|
||||
if (!textures) return createSurfaceRoleMaterial('furnishing', colorPreset)
|
||||
return baseMaterial(shading)
|
||||
}
|
||||
|
||||
const BrokenItemFallback = ({ node }: { node: ItemNode }) => {
|
||||
const handlers = useNodeEvents(node, 'item')
|
||||
const shading = useViewer((s) => s.shading)
|
||||
const [w, h, d] = node.asset.dimensions
|
||||
const material = useMemo(() => {
|
||||
const next = createDefaultMaterial('#ef4444', 1, shading) as MutableMaterial
|
||||
next.opacity = 0.6
|
||||
next.transparent = true
|
||||
next.wireframe = true
|
||||
next.needsUpdate = true
|
||||
return next
|
||||
}, [shading])
|
||||
|
||||
return (
|
||||
<mesh position-y={h / 2} {...handlers}>
|
||||
<boxGeometry args={[w, h, d]} />
|
||||
<meshStandardMaterial color="#ef4444" opacity={0.6} transparent wireframe />
|
||||
<primitive attach="material" object={material} />
|
||||
</mesh>
|
||||
)
|
||||
}
|
||||
@@ -66,21 +94,26 @@ export const ItemRenderer = ({ node }: { node: ItemNode }) => {
|
||||
)
|
||||
}
|
||||
|
||||
const previewMaterial = new MeshStandardNodeMaterial({
|
||||
color: '#cccccc',
|
||||
roughness: 1,
|
||||
metalness: 0,
|
||||
depthTest: false,
|
||||
})
|
||||
|
||||
const previewOpacity = smoothstep(0.42, 0.55, positionLocal.y.add(time.mul(-0.2)).mul(10).fract())
|
||||
const previewMaterialCache = new Map<RenderShading, Material>()
|
||||
|
||||
previewMaterial.opacityNode = previewOpacity
|
||||
previewMaterial.transparent = true
|
||||
function getPreviewMaterial(shading: RenderShading): Material {
|
||||
const cached = previewMaterialCache.get(shading)
|
||||
if (cached) return cached
|
||||
|
||||
const material = createDefaultMaterial('#cccccc', 1, shading) as MutableMaterial
|
||||
material.depthTest = false
|
||||
material.opacityNode = previewOpacity
|
||||
material.transparent = true
|
||||
material.needsUpdate = true
|
||||
previewMaterialCache.set(shading, material)
|
||||
return material
|
||||
}
|
||||
|
||||
const PreviewModel = ({ node }: { node: ItemNode }) => {
|
||||
const shading = useViewer((s) => s.shading)
|
||||
return (
|
||||
<mesh material={previewMaterial} position-y={node.asset.dimensions[1] / 2}>
|
||||
<mesh material={getPreviewMaterial(shading)} position-y={node.asset.dimensions[1] / 2}>
|
||||
<boxGeometry
|
||||
args={[node.asset.dimensions[0], node.asset.dimensions[1], node.asset.dimensions[2]]}
|
||||
/>
|
||||
@@ -97,6 +130,9 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
|
||||
const { scene, nodes, animations } = useGLTF(resolveCdnUrl(node.asset.src) || '')
|
||||
const ref = useRef<Group>(null!)
|
||||
const { actions } = useAnimations(animations, ref)
|
||||
const shading = useViewer((s) => s.shading)
|
||||
const textures = useViewer((s) => s.textures)
|
||||
const colorPreset = useViewer((s) => s.colorPreset)
|
||||
// Freeze the interactive definition at mount — asset schemas don't change at runtime
|
||||
const interactiveRef = useRef(node.asset.interactive)
|
||||
|
||||
@@ -131,7 +167,9 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
|
||||
|
||||
// Handle both single material and material array cases
|
||||
if (Array.isArray(mesh.material)) {
|
||||
mesh.material = mesh.material.map((mat) => getMaterialForOriginal(mat))
|
||||
mesh.material = mesh.material.map((mat) =>
|
||||
getMaterialForOriginal(mat, shading, textures, colorPreset),
|
||||
)
|
||||
hasGlass = mesh.material.some((mat) => mat.name === 'glass')
|
||||
|
||||
// Fix geometry groups that reference materialIndex beyond the material
|
||||
@@ -146,14 +184,14 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
mesh.material = getMaterialForOriginal(mesh.material)
|
||||
mesh.material = getMaterialForOriginal(mesh.material, shading, textures, colorPreset)
|
||||
hasGlass = mesh.material.name === 'glass'
|
||||
}
|
||||
mesh.castShadow = !hasGlass
|
||||
mesh.receiveShadow = !hasGlass
|
||||
}
|
||||
})
|
||||
}, [scene])
|
||||
}, [scene, shading, textures, colorPreset])
|
||||
|
||||
const interactive = interactiveRef.current
|
||||
const animEffect =
|
||||
|
||||
@@ -12,21 +12,26 @@ describe('buildRidgeVentGeometry', () => {
|
||||
})
|
||||
|
||||
test('each style produces a different vertex count (no accidental fallthrough)', () => {
|
||||
const standard = buildRidgeVentGeometry(RidgeVentNode.parse({ style: 'standard' }))
|
||||
.getAttribute('position').count
|
||||
const shingled = buildRidgeVentGeometry(RidgeVentNode.parse({ style: 'shingled' }))
|
||||
.getAttribute('position').count
|
||||
const metal = buildRidgeVentGeometry(RidgeVentNode.parse({ style: 'metal' }))
|
||||
.getAttribute('position').count
|
||||
const standard = buildRidgeVentGeometry(
|
||||
RidgeVentNode.parse({ style: 'standard' }),
|
||||
).getAttribute('position').count
|
||||
const shingled = buildRidgeVentGeometry(
|
||||
RidgeVentNode.parse({ style: 'shingled' }),
|
||||
).getAttribute('position').count
|
||||
const metal = buildRidgeVentGeometry(RidgeVentNode.parse({ style: 'metal' })).getAttribute(
|
||||
'position',
|
||||
).count
|
||||
expect(new Set([standard, shingled, metal]).size).toBe(3)
|
||||
})
|
||||
|
||||
test('endCaps adds vertices on every style', () => {
|
||||
for (const style of ['standard', 'shingled', 'metal'] as const) {
|
||||
const without = buildRidgeVentGeometry(RidgeVentNode.parse({ style, endCaps: false }))
|
||||
.getAttribute('position').count
|
||||
const withCaps = buildRidgeVentGeometry(RidgeVentNode.parse({ style, endCaps: true }))
|
||||
.getAttribute('position').count
|
||||
const without = buildRidgeVentGeometry(
|
||||
RidgeVentNode.parse({ style, endCaps: false }),
|
||||
).getAttribute('position').count
|
||||
const withCaps = buildRidgeVentGeometry(
|
||||
RidgeVentNode.parse({ style, endCaps: true }),
|
||||
).getAttribute('position').count
|
||||
expect(withCaps).toBeGreaterThan(without)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -19,6 +19,7 @@ export const ridgeVentDefinition: NodeDefinition<typeof RidgeVentNode> = {
|
||||
schemaVersion: 1,
|
||||
schema: RidgeVentNode,
|
||||
category: 'structure',
|
||||
surfaceRole: 'roof',
|
||||
|
||||
defaults: () => {
|
||||
const stub = RidgeVentNodeSchema.parse({
|
||||
|
||||
@@ -49,11 +49,7 @@ export function buildRidgeVentGeometry(node: RidgeVentNode): THREE.BufferGeometr
|
||||
|
||||
// ─── Standard curved cap ─────────────────────────────────────────────
|
||||
|
||||
function buildCurvedCapProfile(
|
||||
halfLen: number,
|
||||
halfW: number,
|
||||
h: number,
|
||||
): THREE.BufferGeometry {
|
||||
function buildCurvedCapProfile(halfLen: number, halfW: number, h: number): THREE.BufferGeometry {
|
||||
const positions: number[] = []
|
||||
const normals: number[] = []
|
||||
const uvs: number[] = []
|
||||
@@ -81,15 +77,27 @@ function buildCurvedCapProfile(
|
||||
const fnz = -dy / fLen
|
||||
const fny = dz / fLen
|
||||
|
||||
pushQuad(positions, normals, uvs,
|
||||
[-halfLen, oy0, oz0], [halfLen, oy0, oz0],
|
||||
[halfLen, oy1, oz1], [-halfLen, oy1, oz1],
|
||||
[0, fny, fnz])
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[-halfLen, oy0, oz0],
|
||||
[halfLen, oy0, oz0],
|
||||
[halfLen, oy1, oz1],
|
||||
[-halfLen, oy1, oz1],
|
||||
[0, fny, fnz],
|
||||
)
|
||||
|
||||
pushQuad(positions, normals, uvs,
|
||||
[-halfLen, iy1, iz1], [halfLen, iy1, iz1],
|
||||
[halfLen, iy0, iz0], [-halfLen, iy0, iz0],
|
||||
[0, -fny, -fnz])
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[-halfLen, iy1, iz1],
|
||||
[halfLen, iy1, iz1],
|
||||
[halfLen, iy0, iz0],
|
||||
[-halfLen, iy0, iz0],
|
||||
[0, -fny, -fnz],
|
||||
)
|
||||
}
|
||||
|
||||
// Eave bottoms
|
||||
@@ -97,15 +105,27 @@ function buildCurvedCapProfile(
|
||||
const [oz, oy] = outerPts[idx]!
|
||||
const [iz, iy] = innerPts[idx]!
|
||||
if (idx === 0) {
|
||||
pushQuad(positions, normals, uvs,
|
||||
[-halfLen, iy, iz], [halfLen, iy, iz],
|
||||
[halfLen, oy, oz], [-halfLen, oy, oz],
|
||||
[0, -1, 0])
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[-halfLen, iy, iz],
|
||||
[halfLen, iy, iz],
|
||||
[halfLen, oy, oz],
|
||||
[-halfLen, oy, oz],
|
||||
[0, -1, 0],
|
||||
)
|
||||
} else {
|
||||
pushQuad(positions, normals, uvs,
|
||||
[-halfLen, oy, oz], [halfLen, oy, oz],
|
||||
[halfLen, iy, iz], [-halfLen, iy, iz],
|
||||
[0, -1, 0])
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[-halfLen, oy, oz],
|
||||
[halfLen, oy, oz],
|
||||
[halfLen, iy, iz],
|
||||
[-halfLen, iy, iz],
|
||||
[0, -1, 0],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,11 +180,7 @@ function shingledOuterPts(halfW: number, h: number): [number, number][] {
|
||||
return pts
|
||||
}
|
||||
|
||||
function buildShingledProfile(
|
||||
halfLen: number,
|
||||
halfW: number,
|
||||
h: number,
|
||||
): THREE.BufferGeometry {
|
||||
function buildShingledProfile(halfLen: number, halfW: number, h: number): THREE.BufferGeometry {
|
||||
const positions: number[] = []
|
||||
const normals: number[] = []
|
||||
const uvs: number[] = []
|
||||
@@ -185,34 +201,58 @@ function buildShingledProfile(
|
||||
const fnz = -dy / fLen
|
||||
const fny = dz / fLen
|
||||
|
||||
pushQuad(positions, normals, uvs,
|
||||
[-halfLen, oy0, oz0], [halfLen, oy0, oz0],
|
||||
[halfLen, oy1, oz1], [-halfLen, oy1, oz1],
|
||||
[0, fny, fnz])
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[-halfLen, oy0, oz0],
|
||||
[halfLen, oy0, oz0],
|
||||
[halfLen, oy1, oz1],
|
||||
[-halfLen, oy1, oz1],
|
||||
[0, fny, fnz],
|
||||
)
|
||||
|
||||
pushQuad(positions, normals, uvs,
|
||||
[-halfLen, iy1, iz1], [halfLen, iy1, iz1],
|
||||
[halfLen, iy0, iz0], [-halfLen, iy0, iz0],
|
||||
[0, -fny, -fnz])
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[-halfLen, iy1, iz1],
|
||||
[halfLen, iy1, iz1],
|
||||
[halfLen, iy0, iz0],
|
||||
[-halfLen, iy0, iz0],
|
||||
[0, -fny, -fnz],
|
||||
)
|
||||
}
|
||||
|
||||
// Eave bottoms
|
||||
{
|
||||
const [oz, oy] = outerPts[0]!
|
||||
const [iz, iy] = innerPts[0]!
|
||||
pushQuad(positions, normals, uvs,
|
||||
[-halfLen, iy, iz], [halfLen, iy, iz],
|
||||
[halfLen, oy, oz], [-halfLen, oy, oz],
|
||||
[0, -1, 0])
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[-halfLen, iy, iz],
|
||||
[halfLen, iy, iz],
|
||||
[halfLen, oy, oz],
|
||||
[-halfLen, oy, oz],
|
||||
[0, -1, 0],
|
||||
)
|
||||
}
|
||||
{
|
||||
const last = outerPts.length - 1
|
||||
const [oz, oy] = outerPts[last]!
|
||||
const [iz, iy] = innerPts[last]!
|
||||
pushQuad(positions, normals, uvs,
|
||||
[-halfLen, oy, oz], [halfLen, oy, oz],
|
||||
[halfLen, iy, iz], [-halfLen, iy, iz],
|
||||
[0, -1, 0])
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[-halfLen, oy, oz],
|
||||
[halfLen, oy, oz],
|
||||
[halfLen, iy, iz],
|
||||
[-halfLen, iy, iz],
|
||||
[0, -1, 0],
|
||||
)
|
||||
}
|
||||
|
||||
// Tab divider ridges along the length
|
||||
@@ -236,13 +276,26 @@ function buildShingledProfile(
|
||||
const r0z = oz0 + fnz * ridgeH
|
||||
const r1y = oy1 + fny * ridgeH
|
||||
const r1z = oz1 + fnz * ridgeH
|
||||
pushQuad(positions, normals, uvs,
|
||||
[x, r0y, r0z], [x, r1y, r1z], [x, oy1, oz1], [x, oy0, oz0],
|
||||
[1, 0, 0])
|
||||
pushQuad(positions, normals, uvs,
|
||||
[x, r0y, r0z], [x, r1y, r1z],
|
||||
[x - ridgeD, oy1, oz1], [x - ridgeD, oy0, oz0],
|
||||
[0, fny, fnz])
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[x, r0y, r0z],
|
||||
[x, r1y, r1z],
|
||||
[x, oy1, oz1],
|
||||
[x, oy0, oz0],
|
||||
[1, 0, 0],
|
||||
)
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[x, r0y, r0z],
|
||||
[x, r1y, r1z],
|
||||
[x - ridgeD, oy1, oz1],
|
||||
[x - ridgeD, oy0, oz0],
|
||||
[0, fny, fnz],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,11 +394,7 @@ function segNormal(z0: number, y0: number, z1: number, y1: number): number[] {
|
||||
return [0, dz / len, -dy / len]
|
||||
}
|
||||
|
||||
function buildMetalProfile(
|
||||
halfLen: number,
|
||||
halfW: number,
|
||||
h: number,
|
||||
): THREE.BufferGeometry {
|
||||
function buildMetalProfile(halfLen: number, halfW: number, h: number): THREE.BufferGeometry {
|
||||
const positions: number[] = []
|
||||
const normals: number[] = []
|
||||
const uvs: number[] = []
|
||||
@@ -361,36 +410,56 @@ function buildMetalProfile(
|
||||
const outerN = segNormal(oz0, oy0, oz1, oy1)
|
||||
const innerN = segNormal(iz0, iy0, iz1, iy1).map((v) => -v)
|
||||
|
||||
pushQuad(positions, normals, uvs,
|
||||
[-halfLen, oy0, oz0], [halfLen, oy0, oz0],
|
||||
[halfLen, oy1, oz1], [-halfLen, oy1, oz1],
|
||||
outerN)
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[-halfLen, oy0, oz0],
|
||||
[halfLen, oy0, oz0],
|
||||
[halfLen, oy1, oz1],
|
||||
[-halfLen, oy1, oz1],
|
||||
outerN,
|
||||
)
|
||||
|
||||
pushQuad(positions, normals, uvs,
|
||||
[-halfLen, iy1, iz1], [halfLen, iy1, iz1],
|
||||
[halfLen, iy0, iz0], [-halfLen, iy0, iz0],
|
||||
innerN)
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[-halfLen, iy1, iz1],
|
||||
[halfLen, iy1, iz1],
|
||||
[halfLen, iy0, iz0],
|
||||
[-halfLen, iy0, iz0],
|
||||
innerN,
|
||||
)
|
||||
}
|
||||
|
||||
// Eave bottoms
|
||||
pushQuad(positions, normals, uvs,
|
||||
[-halfLen, inner[0]![1], inner[0]![0]], [halfLen, inner[0]![1], inner[0]![0]],
|
||||
[halfLen, outer[0]![1], outer[0]![0]], [-halfLen, outer[0]![1], outer[0]![0]],
|
||||
[0, -1, 0])
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[-halfLen, inner[0]![1], inner[0]![0]],
|
||||
[halfLen, inner[0]![1], inner[0]![0]],
|
||||
[halfLen, outer[0]![1], outer[0]![0]],
|
||||
[-halfLen, outer[0]![1], outer[0]![0]],
|
||||
[0, -1, 0],
|
||||
)
|
||||
const last = outer.length - 1
|
||||
pushQuad(positions, normals, uvs,
|
||||
[-halfLen, outer[last]![1], outer[last]![0]], [halfLen, outer[last]![1], outer[last]![0]],
|
||||
[halfLen, inner[last]![1], inner[last]![0]], [-halfLen, inner[last]![1], inner[last]![0]],
|
||||
[0, -1, 0])
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[-halfLen, outer[last]![1], outer[last]![0]],
|
||||
[halfLen, outer[last]![1], outer[last]![0]],
|
||||
[halfLen, inner[last]![1], inner[last]![0]],
|
||||
[-halfLen, inner[last]![1], inner[last]![0]],
|
||||
[0, -1, 0],
|
||||
)
|
||||
|
||||
return buildBufferGeometry(positions, normals, uvs)
|
||||
}
|
||||
|
||||
function buildMetalEndCaps(
|
||||
halfLen: number,
|
||||
halfW: number,
|
||||
h: number,
|
||||
): THREE.BufferGeometry | null {
|
||||
function buildMetalEndCaps(halfLen: number, halfW: number, h: number): THREE.BufferGeometry | null {
|
||||
const positions: number[] = []
|
||||
const normals: number[] = []
|
||||
const uvs: number[] = []
|
||||
@@ -415,10 +484,7 @@ function buildMetalEndCaps(
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function offsetProfileInward(
|
||||
pts: [number, number][],
|
||||
t: number,
|
||||
): [number, number][] {
|
||||
function offsetProfileInward(pts: [number, number][], t: number): [number, number][] {
|
||||
const result: [number, number][] = []
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
const [z, y] = pts[i]!
|
||||
|
||||
@@ -51,15 +51,9 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
|
||||
const ventObj = sceneRegistry.nodes.get(node.id)
|
||||
if (ventObj) ventObj.visible = false
|
||||
|
||||
const worldToBuildingLocal = (
|
||||
wx: number,
|
||||
wy: number,
|
||||
wz: number,
|
||||
): [number, number, number] => {
|
||||
const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => {
|
||||
const buildingId = useViewer.getState().selection.buildingId
|
||||
const buildingObj = buildingId
|
||||
? sceneRegistry.nodes.get(buildingId as AnyNodeId)
|
||||
: null
|
||||
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
|
||||
if (!buildingObj) return [wx, wy, wz]
|
||||
const v = new THREE.Vector3(wx, wy, wz)
|
||||
buildingObj.worldToLocal(v)
|
||||
@@ -142,9 +136,7 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
|
||||
if (isNew) {
|
||||
const parentId = original.roofSegmentId as AnyNodeId | undefined
|
||||
if (parentId) {
|
||||
const parent = useScene.getState().nodes[parentId] as
|
||||
| RoofSegmentNode
|
||||
| undefined
|
||||
const parent = useScene.getState().nodes[parentId] as RoofSegmentNode | undefined
|
||||
if (parent) {
|
||||
useScene.getState().updateNode(parentId, {
|
||||
children: (parent.children ?? []).filter((id) => id !== node.id),
|
||||
|
||||
@@ -6,13 +6,10 @@ import { buildRidgeVentGeometry } from './geometry'
|
||||
import type { RidgeVentNode } from './schema'
|
||||
|
||||
const RidgeVentPreview = ({ node }: { node: RidgeVentNode }) => {
|
||||
const geometry = useMemo(() => buildRidgeVentGeometry(node), [
|
||||
node.length,
|
||||
node.width,
|
||||
node.height,
|
||||
node.style,
|
||||
node.endCaps,
|
||||
])
|
||||
const geometry = useMemo(
|
||||
() => buildRidgeVentGeometry(node),
|
||||
[node.length, node.width, node.height, node.style, node.endCaps],
|
||||
)
|
||||
|
||||
const material = useMemo(
|
||||
() =>
|
||||
@@ -51,12 +48,7 @@ const RidgeVentPreview = ({ node }: { node: RidgeVentNode }) => {
|
||||
}}
|
||||
/>
|
||||
<lineSegments geometry={edgesGeometry} renderOrder={1000}>
|
||||
<lineBasicMaterial
|
||||
color={0x6c_a3_ff}
|
||||
depthTest={false}
|
||||
opacity={0.9}
|
||||
transparent
|
||||
/>
|
||||
<lineBasicMaterial color={0x6c_a3_ff} depthTest={false} opacity={0.9} transparent />
|
||||
</lineSegments>
|
||||
</group>
|
||||
)
|
||||
|
||||
@@ -7,7 +7,14 @@ import {
|
||||
useRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { createMaterial, createMaterialFromPresetRef, useNodeEvents } from '@pascal-app/viewer'
|
||||
import {
|
||||
type ColorPreset,
|
||||
createMaterial,
|
||||
createMaterialFromPresetRef,
|
||||
createSurfaceRoleMaterial,
|
||||
useNodeEvents,
|
||||
useViewer,
|
||||
} from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { buildRidgeVentGeometry } from './geometry'
|
||||
@@ -42,6 +49,10 @@ const RidgeVentRenderer = ({ node }: { node: RidgeVentNode }) => {
|
||||
const ref = useRef<THREE.Group>(null!)
|
||||
useRegistry(node.id, 'ridge-vent', ref)
|
||||
const handlers = useNodeEvents(node, 'ridge-vent')
|
||||
const shading = useViewer((s) => s.shading)
|
||||
const textures = useViewer((s) => s.textures)
|
||||
const colorPreset: ColorPreset = useViewer((s) => s.colorPreset)
|
||||
const sceneTheme = useViewer((s) => s.sceneTheme)
|
||||
|
||||
const segment = useScene((state) =>
|
||||
node.roofSegmentId
|
||||
@@ -49,13 +60,10 @@ const RidgeVentRenderer = ({ node }: { node: RidgeVentNode }) => {
|
||||
: undefined,
|
||||
)
|
||||
|
||||
const geometry = useMemo(() => buildRidgeVentGeometry(node), [
|
||||
node.length,
|
||||
node.width,
|
||||
node.height,
|
||||
node.style,
|
||||
node.endCaps,
|
||||
])
|
||||
const geometry = useMemo(
|
||||
() => buildRidgeVentGeometry(node),
|
||||
[node.length, node.width, node.height, node.style, node.endCaps],
|
||||
)
|
||||
|
||||
useEffect(() => () => geometry.dispose(), [geometry])
|
||||
|
||||
@@ -65,14 +73,20 @@ const RidgeVentRenderer = ({ node }: { node: RidgeVentNode }) => {
|
||||
// — so clone the resolved material and force `DoubleSide` locally
|
||||
// without mutating the shared cache entry.
|
||||
const material = useMemo(() => {
|
||||
// Untextured ridge vent (and textures-off mode) takes the themed
|
||||
// 'roof' role colour. Request DoubleSide directly so the cached role
|
||||
// material is the right side — no clone/mutation of a shared material.
|
||||
if (!textures || (!node.material && !node.materialPreset)) {
|
||||
return createSurfaceRoleMaterial('roof', colorPreset, THREE.DoubleSide, sceneTheme)
|
||||
}
|
||||
const base = node.material
|
||||
? createMaterial(node.material)
|
||||
: (createMaterialFromPresetRef(node.materialPreset) ?? defaultMaterial)
|
||||
? createMaterial(node.material, shading)
|
||||
: (createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial)
|
||||
if (base.side === THREE.DoubleSide) return base
|
||||
const cloned = base.clone()
|
||||
cloned.side = THREE.DoubleSide
|
||||
return cloned
|
||||
}, [node.material, node.materialPreset])
|
||||
}, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset])
|
||||
|
||||
if (!segment) return null
|
||||
|
||||
|
||||
@@ -48,11 +48,7 @@ const RidgeVentTool = () => {
|
||||
useEffect(() => {
|
||||
if (!activeBuildingId) return
|
||||
|
||||
const worldToBuildingLocal = (
|
||||
wx: number,
|
||||
wy: number,
|
||||
wz: number,
|
||||
): [number, number, number] => {
|
||||
const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => {
|
||||
const buildingObj = sceneRegistry.nodes.get(activeBuildingId as AnyNodeId)
|
||||
if (!buildingObj) return [wx, wy, wz]
|
||||
worldPoint.set(wx, wy, wz)
|
||||
|
||||
@@ -14,6 +14,7 @@ export const roofSegmentDefinition: NodeDefinition<typeof RoofSegmentNode> = {
|
||||
schemaVersion: 1,
|
||||
schema: RoofSegmentNode,
|
||||
category: 'structure',
|
||||
surfaceRole: 'roof',
|
||||
|
||||
defaults: () => {
|
||||
const stub = RoofSegmentNodeSchema.parse({
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { roofDebugMaterials, roofMaterials } from '../roof/roof-materials'
|
||||
import { getRoofDebugMaterials, getRoofMaterials } from '../roof/roof-materials'
|
||||
|
||||
export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
|
||||
const ref = useRef<THREE.Mesh>(null!)
|
||||
@@ -29,6 +29,10 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
|
||||
|
||||
const handlers = useNodeEvents(node, 'roof-segment')
|
||||
const debugColors = useViewer((s) => s.debugColors)
|
||||
const shading = useViewer((s) => s.shading)
|
||||
const textures = useViewer((s) => s.textures)
|
||||
const colorPreset = useViewer((s) => s.colorPreset)
|
||||
const sceneTheme = useViewer((s) => s.sceneTheme)
|
||||
const parentNode = node.parentId
|
||||
? (nodes[node.parentId as AnyNodeId] as RoofNode | undefined)
|
||||
: undefined
|
||||
@@ -59,32 +63,37 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
|
||||
const parentSpec = parentNode ? getEffectiveRoofSurfaceMaterial(parentNode, role) : undefined
|
||||
const spec = getEffectiveSegmentSurfaceMaterial(node, role, parentSpec)
|
||||
if (typeof spec.materialPreset === 'string') {
|
||||
const resolved = createMaterialFromPresetRef(spec.materialPreset)
|
||||
const resolved = createMaterialFromPresetRef(spec.materialPreset, shading)
|
||||
if (resolved) return resolved
|
||||
}
|
||||
if (spec.material !== undefined) {
|
||||
return createMaterial(spec.material)
|
||||
return createMaterial(spec.material, shading)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Themed parent-roof array (per-role scene-theme colours) — used both as the
|
||||
// full fallback and to fill any individual untextured slot below.
|
||||
const themedArray = parentNode
|
||||
? getRoofMaterialArray(parentNode, shading, textures, colorPreset, sceneTheme)
|
||||
: null
|
||||
|
||||
const edge = resolveSlot('edge')
|
||||
const wall = resolveSlot('wall')
|
||||
const top = resolveSlot('top')
|
||||
|
||||
if (!(edge || wall || top)) {
|
||||
// Nothing set anywhere — fall back to the parent roof's array (which
|
||||
// applies its own per-role resolution + defaults) or to null so the
|
||||
// renderer picks the package-level `roofMaterials` defaults.
|
||||
return parentNode ? getRoofMaterialArray(parentNode) : null
|
||||
return themedArray
|
||||
}
|
||||
|
||||
const fallback = () => new THREE.MeshStandardMaterial()
|
||||
// Some slots have explicit materials; fill the rest from the themed array so
|
||||
// an untextured slot still picks up the scene-theme role colour, not blank white.
|
||||
const slot = (i: number) => themedArray?.[i] ?? new THREE.MeshStandardMaterial()
|
||||
return [
|
||||
edge ?? wall ?? top ?? fallback(),
|
||||
wall ?? edge ?? top ?? fallback(),
|
||||
wall ?? edge ?? top ?? fallback(),
|
||||
top ?? wall ?? edge ?? fallback(),
|
||||
edge ?? wall ?? top ?? slot(0),
|
||||
wall ?? edge ?? top ?? slot(1),
|
||||
wall ?? edge ?? top ?? slot(2),
|
||||
top ?? wall ?? edge ?? slot(3),
|
||||
] as THREE.Material[]
|
||||
}, [
|
||||
node.material,
|
||||
@@ -96,9 +105,15 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
|
||||
node.wallMaterial,
|
||||
node.wallMaterialPreset,
|
||||
parentNode,
|
||||
shading,
|
||||
textures,
|
||||
colorPreset,
|
||||
sceneTheme,
|
||||
])
|
||||
|
||||
const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials
|
||||
const material = debugColors
|
||||
? getRoofDebugMaterials(shading)
|
||||
: customMaterial || getRoofMaterials(shading, textures, colorPreset)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
||||
@@ -16,6 +16,7 @@ export const roofDefinition: NodeDefinition<typeof RoofNode> = {
|
||||
schemaVersion: 1,
|
||||
schema: RoofNode,
|
||||
category: 'structure',
|
||||
surfaceRole: 'roof',
|
||||
|
||||
defaults: () => {
|
||||
const stub = RoofNodeSchema.parse({ id: 'roof_default' as never, type: 'roof' })
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { roofDefinition } from './definition'
|
||||
export { resolveRoofSegmentHit, type RoofSegmentHit } from './segment-hit'
|
||||
export { type RoofSegmentHit, resolveRoofSegmentHit } from './segment-hit'
|
||||
|
||||
@@ -207,13 +207,7 @@ export default function RoofPanel() {
|
||||
// Same code path as the top palette — see `tool-manager.tsx:28`'s
|
||||
// `nodeRegistry.get(tool)?.tool` dispatch.
|
||||
const activateTool = useCallback(
|
||||
(kind:
|
||||
| 'box-vent'
|
||||
| 'ridge-vent'
|
||||
| 'chimney'
|
||||
| 'solar-panel'
|
||||
| 'skylight'
|
||||
| 'dormer') => {
|
||||
(kind: 'box-vent' | 'ridge-vent' | 'chimney' | 'solar-panel' | 'skylight' | 'dormer') => {
|
||||
triggerSFX('sfx:item-pick')
|
||||
useEditor.getState().setTool(kind)
|
||||
if (useEditor.getState().mode !== 'build') {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { getRoofMaterialArray, NodeRenderer, useNodeEvents, useViewer } from '@p
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { roofDebugMaterials, roofMaterials } from './roof-materials'
|
||||
import { getRoofDebugMaterials, getRoofMaterials } from './roof-materials'
|
||||
|
||||
export const RoofRenderer = ({ node }: { node: RoofNode }) => {
|
||||
const ref = useRef<THREE.Group>(null!)
|
||||
@@ -24,6 +24,10 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => {
|
||||
|
||||
const handlers = useNodeEvents(node, 'roof')
|
||||
const debugColors = useViewer((s) => s.debugColors)
|
||||
const shading = useViewer((s) => s.shading)
|
||||
const textures = useViewer((s) => s.textures)
|
||||
const colorPreset = useViewer((s) => s.colorPreset)
|
||||
const sceneTheme = useViewer((s) => s.sceneTheme)
|
||||
|
||||
// Collect roof element IDs (chimneys, skylights, etc.) hosted by any segment.
|
||||
// Rendered outside segments-wrapper (invisible during normal mode) so elements
|
||||
@@ -80,9 +84,14 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => {
|
||||
return geometry
|
||||
}, [])
|
||||
|
||||
const customMaterial = useMemo(() => getRoofMaterialArray(node), [node])
|
||||
const customMaterial = useMemo(
|
||||
() => getRoofMaterialArray(node, shading, textures, colorPreset, sceneTheme),
|
||||
[node, shading, textures, colorPreset, sceneTheme],
|
||||
)
|
||||
|
||||
const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials
|
||||
const material = debugColors
|
||||
? getRoofDebugMaterials(shading)
|
||||
: customMaterial || getRoofMaterials(shading, textures, colorPreset)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
||||
@@ -1,18 +1,54 @@
|
||||
import {
|
||||
type ColorPreset,
|
||||
createDefaultMaterial,
|
||||
createSurfaceRoleMaterial,
|
||||
type RenderShading,
|
||||
} from '@pascal-app/viewer'
|
||||
import * as THREE from 'three'
|
||||
|
||||
// Production materials — match the rest of the scene (white walls, light-gray slabs).
|
||||
// Indices: 0 = Wall/Trim, 1 = Deck, 2 = Interior, 3 = Shingle
|
||||
export const roofMaterials: THREE.Material[] = [
|
||||
new THREE.MeshStandardMaterial({ color: 'white', roughness: 1, side: THREE.DoubleSide }), // 0: Wall/Trim
|
||||
new THREE.MeshStandardMaterial({ color: '#e5e5e5', roughness: 1, side: THREE.FrontSide }), // 1: Deck
|
||||
new THREE.MeshStandardMaterial({ color: 'white', roughness: 1, side: THREE.DoubleSide }), // 2: Interior
|
||||
new THREE.MeshStandardMaterial({ color: '#e5e5e5', roughness: 0.9, side: THREE.FrontSide }), // 3: Shingle
|
||||
]
|
||||
const roofMaterialsCache = new Map<string, THREE.Material[]>()
|
||||
|
||||
export function getRoofMaterials(
|
||||
shading: RenderShading = 'rendered',
|
||||
textures = true,
|
||||
colorPreset: ColorPreset = 'clay',
|
||||
): THREE.Material[] {
|
||||
const cacheKey = `${shading}-${textures}-${colorPreset}`
|
||||
const cached = roofMaterialsCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const materials = textures
|
||||
? [
|
||||
createDefaultMaterial('white', 1, shading, THREE.DoubleSide), // 0: Wall/Trim
|
||||
createDefaultMaterial('#e5e5e5', 1, shading, THREE.FrontSide), // 1: Deck
|
||||
createDefaultMaterial('white', 1, shading, THREE.DoubleSide), // 2: Interior
|
||||
createDefaultMaterial('#e5e5e5', 0.9, shading, THREE.FrontSide), // 3: Shingle
|
||||
]
|
||||
: [
|
||||
createSurfaceRoleMaterial('roof', colorPreset),
|
||||
createSurfaceRoleMaterial('ceiling', colorPreset),
|
||||
createSurfaceRoleMaterial('ceiling', colorPreset),
|
||||
createSurfaceRoleMaterial('roof', colorPreset),
|
||||
]
|
||||
roofMaterialsCache.set(cacheKey, materials)
|
||||
return materials
|
||||
}
|
||||
|
||||
// Debug materials — vivid, distinct colours to identify each surface group.
|
||||
export const roofDebugMaterials: THREE.Material[] = [
|
||||
new THREE.MeshStandardMaterial({ color: '#eaeaea', roughness: 0.8, side: THREE.DoubleSide }), // 0: Wall
|
||||
new THREE.MeshStandardMaterial({ color: '#000000', roughness: 0.9, side: THREE.FrontSide }), // 1: Deck
|
||||
new THREE.MeshStandardMaterial({ color: '#dddddd', roughness: 0.9, side: THREE.DoubleSide }), // 2: Interior
|
||||
new THREE.MeshStandardMaterial({ color: '#4ade80', roughness: 0.9, side: THREE.FrontSide }), // 3: Shingle
|
||||
]
|
||||
const roofDebugMaterialsCache = new Map<RenderShading, THREE.Material[]>()
|
||||
|
||||
export function getRoofDebugMaterials(shading: RenderShading = 'rendered'): THREE.Material[] {
|
||||
const cached = roofDebugMaterialsCache.get(shading)
|
||||
if (cached) return cached
|
||||
|
||||
const materials = [
|
||||
createDefaultMaterial('#eaeaea', 0.8, shading, THREE.DoubleSide), // 0: Wall
|
||||
createDefaultMaterial('#000000', 0.9, shading, THREE.FrontSide), // 1: Deck
|
||||
createDefaultMaterial('#dddddd', 0.9, shading, THREE.DoubleSide), // 2: Interior
|
||||
createDefaultMaterial('#4ade80', 0.9, shading, THREE.FrontSide), // 3: Shingle
|
||||
]
|
||||
roofDebugMaterialsCache.set(shading, materials)
|
||||
return materials
|
||||
}
|
||||
|
||||
@@ -29,7 +29,12 @@ function analyticalSurfaceY(seg: RoofSegmentNode, lx: number, lz: number): numbe
|
||||
const peakY = seg.wallHeight + rh
|
||||
if (rh === 0) return seg.wallHeight
|
||||
|
||||
if (seg.roofType === 'gable' || seg.roofType === 'gambrel' || seg.roofType === 'mansard' || seg.roofType === 'dutch') {
|
||||
if (
|
||||
seg.roofType === 'gable' ||
|
||||
seg.roofType === 'gambrel' ||
|
||||
seg.roofType === 'mansard' ||
|
||||
seg.roofType === 'dutch'
|
||||
) {
|
||||
const t = seg.depth > 0 ? Math.abs(lz) / (seg.depth / 2) : 0
|
||||
return peakY - t * rh
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ export const shelfDefinition: NodeDefinition<typeof ShelfNode> = {
|
||||
schemaVersion: 2,
|
||||
schema: ShelfNode,
|
||||
category: 'furnish',
|
||||
surfaceRole: 'joinery',
|
||||
|
||||
defaults: () => ({
|
||||
object: 'node',
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { getMaterialPresetByRef } from '@pascal-app/core'
|
||||
import {
|
||||
applyMaterialPresetToMaterials,
|
||||
createDefaultMaterial,
|
||||
createMaterial,
|
||||
DEFAULT_SHELF_MATERIAL,
|
||||
type RenderShading,
|
||||
} from '@pascal-app/viewer'
|
||||
import { BoxGeometry, FrontSide, Group, Mesh, MeshStandardMaterial } from 'three'
|
||||
import { BoxGeometry, FrontSide, Group, type Material, Mesh } from 'three'
|
||||
import type { ShelfNode } from './schema'
|
||||
|
||||
/**
|
||||
@@ -31,10 +33,15 @@ import type { ShelfNode } from './schema'
|
||||
* Style dispatch lives at the top of the function; each style helper
|
||||
* mutates the same `group`.
|
||||
*/
|
||||
const shelfMaterialCache = new Map<string, MeshStandardMaterial>()
|
||||
type ShelfMaterial = Material & {
|
||||
depthWrite: boolean
|
||||
}
|
||||
|
||||
function getShelfMaterial(node: ShelfNode): MeshStandardMaterial {
|
||||
const shelfMaterialCache = new Map<string, Material>()
|
||||
|
||||
function getShelfMaterial(node: ShelfNode, shading: RenderShading): Material {
|
||||
const cacheKey = JSON.stringify({
|
||||
shading,
|
||||
material: node.material ?? null,
|
||||
materialPreset: node.materialPreset ?? null,
|
||||
})
|
||||
@@ -43,28 +50,33 @@ function getShelfMaterial(node: ShelfNode): MeshStandardMaterial {
|
||||
|
||||
const preset = getMaterialPresetByRef(node.materialPreset)
|
||||
const material = preset
|
||||
? new MeshStandardMaterial()
|
||||
? createDefaultMaterial('#ffffff', 0.5, shading)
|
||||
: node.material
|
||||
? createMaterial(node.material).clone()
|
||||
: DEFAULT_SHELF_MATERIAL.clone()
|
||||
? createMaterial(node.material, shading).clone()
|
||||
: DEFAULT_SHELF_MATERIAL(shading).clone()
|
||||
|
||||
if (preset) {
|
||||
applyMaterialPresetToMaterials(material, preset)
|
||||
}
|
||||
|
||||
material.side = FrontSide
|
||||
material.depthWrite = true
|
||||
material.needsUpdate = true
|
||||
const shelfMaterial = material as ShelfMaterial
|
||||
shelfMaterial.side = FrontSide
|
||||
shelfMaterial.depthWrite = true
|
||||
shelfMaterial.needsUpdate = true
|
||||
|
||||
shelfMaterialCache.set(cacheKey, material)
|
||||
return material
|
||||
}
|
||||
|
||||
export function buildShelfGeometry(node: ShelfNode): Group {
|
||||
export function buildShelfGeometry(
|
||||
node: ShelfNode,
|
||||
_ctx?: unknown,
|
||||
shading: RenderShading = 'rendered',
|
||||
): Group {
|
||||
const group = new Group()
|
||||
group.name = 'shelf-geometry'
|
||||
|
||||
const material = getShelfMaterial(node)
|
||||
const material = getShelfMaterial(node, shading)
|
||||
|
||||
switch (node.style) {
|
||||
case 'wall-shelf':
|
||||
@@ -91,7 +103,7 @@ export function buildShelfGeometry(node: ShelfNode): Group {
|
||||
* evenly-spaced boards from `height/rows` up to `height`. Brackets
|
||||
* span from floor to the topmost board.
|
||||
*/
|
||||
function buildWallShelf(group: Group, node: ShelfNode, material: MeshStandardMaterial) {
|
||||
function buildWallShelf(group: Group, node: ShelfNode, material: Material) {
|
||||
for (const y of boardCenterYs(node)) {
|
||||
const board = new Mesh(new BoxGeometry(node.width, node.thickness, node.depth), material)
|
||||
board.name = `shelf-board-${boardRowIndex(node, y)}`
|
||||
@@ -123,7 +135,7 @@ function buildWallShelf(group: Group, node: ShelfNode, material: MeshStandardMat
|
||||
* `withSides === false`, side panels become slim corner posts (a rack
|
||||
* silhouette).
|
||||
*/
|
||||
function buildBookshelf(group: Group, node: ShelfNode, material: MeshStandardMaterial) {
|
||||
function buildBookshelf(group: Group, node: ShelfNode, material: Material) {
|
||||
const unitHeight = node.height + node.thickness
|
||||
const innerWidth = node.withSides ? node.width - 2 * node.thickness : node.width
|
||||
|
||||
@@ -179,7 +191,7 @@ function buildBookshelf(group: Group, node: ShelfNode, material: MeshStandardMat
|
||||
* X-brace on the back face for stability. `withSides` / `bracketStyle`
|
||||
* are ignored (the rack defines its own posts).
|
||||
*/
|
||||
function buildOpenRack(group: Group, node: ShelfNode, material: MeshStandardMaterial) {
|
||||
function buildOpenRack(group: Group, node: ShelfNode, material: Material) {
|
||||
const unitHeight = node.height + node.thickness
|
||||
const innerWidth = node.width
|
||||
const boardThickness = Math.max(0.02, node.thickness * 0.8)
|
||||
@@ -212,7 +224,7 @@ function buildOpenRack(group: Group, node: ShelfNode, material: MeshStandardMate
|
||||
* boards + vertical dividers. `withBack` / `withSides` are forced on
|
||||
* because the cubby shape requires them.
|
||||
*/
|
||||
function buildCubby(group: Group, node: ShelfNode, material: MeshStandardMaterial) {
|
||||
function buildCubby(group: Group, node: ShelfNode, material: Material) {
|
||||
const unitHeight = node.height + node.thickness
|
||||
const innerWidth = node.width - 2 * node.thickness
|
||||
|
||||
@@ -294,7 +306,7 @@ function boardRowIndex(node: ShelfNode, y: number): number {
|
||||
function addCornerPosts(
|
||||
group: Group,
|
||||
node: ShelfNode,
|
||||
material: MeshStandardMaterial,
|
||||
material: Material,
|
||||
unitHeight: number,
|
||||
postStyle: 'rack' | 'leg',
|
||||
) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import type { MeshStandardMaterial } from 'three'
|
||||
import type { Material } from 'three'
|
||||
import { buildShelfGeometry } from './geometry'
|
||||
import type { ShelfNode } from './schema'
|
||||
|
||||
@@ -13,7 +14,7 @@ import type { ShelfNode } from './schema'
|
||||
* the clone for a translucent ghost.
|
||||
*
|
||||
* Cloning is non-negotiable: `getShelfMaterial` caches the default
|
||||
* `MeshStandardMaterial` instance in a module-scoped map keyed on
|
||||
* material instance in a module-scoped map keyed on
|
||||
* `material` / `materialPreset`, so every unpainted shelf in the scene
|
||||
* shares the same material. Mutating `mat.transparent = true` here
|
||||
* would leak into every committed shelf and render them all see-through.
|
||||
@@ -33,24 +34,25 @@ import type { ShelfNode } from './schema'
|
||||
* to the grid plane below.
|
||||
*/
|
||||
const ShelfPreview = ({ node }: { node: ShelfNode }) => {
|
||||
const built = useMemo(() => buildShelfGeometry(node), [node])
|
||||
const shading = useViewer((s) => s.shading)
|
||||
const built = useMemo(() => buildShelfGeometry(node, undefined, shading), [node, shading])
|
||||
|
||||
useEffect(() => {
|
||||
const cloned: MeshStandardMaterial[] = []
|
||||
const cloned: Material[] = []
|
||||
built.traverse((obj) => {
|
||||
// Skip pointer events: see component-level note above.
|
||||
;(obj as unknown as { raycast: () => void }).raycast = () => {}
|
||||
|
||||
// `Mesh.material` is typed as `Material | Material[]` upstream;
|
||||
// every shelf board carries a `MeshStandardMaterial` from
|
||||
// every shelf board carries a material from
|
||||
// `getShelfMaterial`. Access through a structural cast keeps the
|
||||
// assignment well-typed without depending on the Mesh union.
|
||||
const mesh = obj as {
|
||||
material?: MeshStandardMaterial | MeshStandardMaterial[]
|
||||
material?: Material | Material[]
|
||||
}
|
||||
if (!mesh.material) return
|
||||
|
||||
const cloneAndSwap = (mat: MeshStandardMaterial): MeshStandardMaterial => {
|
||||
const cloneAndSwap = (mat: Material): Material => {
|
||||
const c = mat.clone()
|
||||
c.transparent = true
|
||||
c.opacity = 0.5
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNodeId, type SiteNode, type SlabNode, useRegistry, useScene } from '@pascal-app/core'
|
||||
import { NodeRenderer, unionPolygons, useNodeEvents, useViewer } from '@pascal-app/viewer'
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type SiteNode,
|
||||
type SlabNode,
|
||||
useRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
getSceneTheme,
|
||||
NodeRenderer,
|
||||
unionPolygons,
|
||||
useNodeEvents,
|
||||
useViewer,
|
||||
} from '@pascal-app/viewer'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { BufferGeometry, Float32BufferAttribute, type Group, Path, Shape } from 'three'
|
||||
import { MeshLambertNodeMaterial } from 'three/webgpu'
|
||||
|
||||
const Y_OFFSET = 0.01
|
||||
|
||||
@@ -37,8 +50,18 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
|
||||
|
||||
useRegistry(node.id, 'site', ref)
|
||||
|
||||
const theme = useViewer((state) => state.theme)
|
||||
const bgColor = theme === 'dark' ? '#1f2433' : '#fafafa'
|
||||
const bgColor = useViewer((state) => getSceneTheme(state.sceneTheme).ground)
|
||||
|
||||
// Lit (not Basic) so the site ground receives the directional shadow — Basic
|
||||
// is unlit, which is why shadows used to stop dead at the slab edge. polygonOffset
|
||||
// keeps it tucked behind the grid/slab as before.
|
||||
const groundMaterial = useMemo(() => {
|
||||
const material = new MeshLambertNodeMaterial({ color: bgColor })
|
||||
material.polygonOffset = true
|
||||
material.polygonOffsetFactor = 1
|
||||
material.polygonOffsetUnits = 1
|
||||
return material
|
||||
}, [bgColor])
|
||||
|
||||
// Cache slab polygon references to keep the selector stable across unrelated store updates
|
||||
const slabPolygonsCache = useRef<[number, number][][]>([])
|
||||
@@ -122,14 +145,13 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
|
||||
|
||||
{/* Ground fill: site polygon with slab holes, occludes below-grade geometry */}
|
||||
{groundShape && (
|
||||
<mesh position={[0, -0.05, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<mesh
|
||||
material={groundMaterial}
|
||||
position={[0, -0.05, 0]}
|
||||
receiveShadow
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
>
|
||||
<shapeGeometry args={[groundShape]} />
|
||||
<meshBasicMaterial
|
||||
color={bgColor}
|
||||
polygonOffset={true}
|
||||
polygonOffsetFactor={1}
|
||||
polygonOffsetUnits={1}
|
||||
/>
|
||||
</mesh>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ import {
|
||||
type AnyNode,
|
||||
type NodeDefinition,
|
||||
type RoofSegmentNode,
|
||||
type SkylightNode as SkylightNodeType,
|
||||
SkylightNode as SkylightNodeSchema,
|
||||
type SkylightNode as SkylightNodeType,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
closeSkylightOpenState,
|
||||
@@ -25,6 +25,7 @@ export const skylightDefinition: NodeDefinition<typeof SkylightNode> = {
|
||||
schemaVersion: 1,
|
||||
schema: SkylightNode,
|
||||
category: 'structure',
|
||||
surfaceRole: 'glazing',
|
||||
|
||||
defaults: () => {
|
||||
const stub = SkylightNodeSchema.parse({
|
||||
|
||||
@@ -27,16 +27,92 @@ export function buildLanternGlassGeometry(
|
||||
const positions =
|
||||
resolvedTopScale <= 1e-4
|
||||
? [
|
||||
-baseHalfW, 0, baseHalfD, baseHalfW, 0, baseHalfD, 0, topY, 0,
|
||||
baseHalfW, 0, baseHalfD, baseHalfW, 0, -baseHalfD, 0, topY, 0,
|
||||
baseHalfW, 0, -baseHalfD, -baseHalfW, 0, -baseHalfD, 0, topY, 0,
|
||||
-baseHalfW, 0, -baseHalfD, -baseHalfW, 0, baseHalfD, 0, topY, 0,
|
||||
-baseHalfW,
|
||||
0,
|
||||
baseHalfD,
|
||||
baseHalfW,
|
||||
0,
|
||||
baseHalfD,
|
||||
0,
|
||||
topY,
|
||||
0,
|
||||
baseHalfW,
|
||||
0,
|
||||
baseHalfD,
|
||||
baseHalfW,
|
||||
0,
|
||||
-baseHalfD,
|
||||
0,
|
||||
topY,
|
||||
0,
|
||||
baseHalfW,
|
||||
0,
|
||||
-baseHalfD,
|
||||
-baseHalfW,
|
||||
0,
|
||||
-baseHalfD,
|
||||
0,
|
||||
topY,
|
||||
0,
|
||||
-baseHalfW,
|
||||
0,
|
||||
-baseHalfD,
|
||||
-baseHalfW,
|
||||
0,
|
||||
baseHalfD,
|
||||
0,
|
||||
topY,
|
||||
0,
|
||||
]
|
||||
: [
|
||||
-baseHalfW, 0, baseHalfD, baseHalfW, 0, baseHalfD, topHalfW, topY, topHalfD, -topHalfW, topY, topHalfD,
|
||||
baseHalfW, 0, baseHalfD, baseHalfW, 0, -baseHalfD, topHalfW, topY, -topHalfD, topHalfW, topY, topHalfD,
|
||||
baseHalfW, 0, -baseHalfD, -baseHalfW, 0, -baseHalfD, -topHalfW, topY, -topHalfD, topHalfW, topY, -topHalfD,
|
||||
-baseHalfW, 0, -baseHalfD, -baseHalfW, 0, baseHalfD, -topHalfW, topY, topHalfD, -topHalfW, topY, -topHalfD,
|
||||
-baseHalfW,
|
||||
0,
|
||||
baseHalfD,
|
||||
baseHalfW,
|
||||
0,
|
||||
baseHalfD,
|
||||
topHalfW,
|
||||
topY,
|
||||
topHalfD,
|
||||
-topHalfW,
|
||||
topY,
|
||||
topHalfD,
|
||||
baseHalfW,
|
||||
0,
|
||||
baseHalfD,
|
||||
baseHalfW,
|
||||
0,
|
||||
-baseHalfD,
|
||||
topHalfW,
|
||||
topY,
|
||||
-topHalfD,
|
||||
topHalfW,
|
||||
topY,
|
||||
topHalfD,
|
||||
baseHalfW,
|
||||
0,
|
||||
-baseHalfD,
|
||||
-baseHalfW,
|
||||
0,
|
||||
-baseHalfD,
|
||||
-topHalfW,
|
||||
topY,
|
||||
-topHalfD,
|
||||
topHalfW,
|
||||
topY,
|
||||
-topHalfD,
|
||||
-baseHalfW,
|
||||
0,
|
||||
-baseHalfD,
|
||||
-baseHalfW,
|
||||
0,
|
||||
baseHalfD,
|
||||
-topHalfW,
|
||||
topY,
|
||||
topHalfD,
|
||||
-topHalfW,
|
||||
topY,
|
||||
-topHalfD,
|
||||
]
|
||||
const indices =
|
||||
resolvedTopScale <= 1e-4
|
||||
|
||||
@@ -6,15 +6,11 @@ import {
|
||||
type RoofEvent,
|
||||
type RoofNode,
|
||||
type RoofSegmentNode,
|
||||
sceneRegistry,
|
||||
type SkylightNode,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
markToolCancelConsumed,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
@@ -75,11 +71,7 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) {
|
||||
const skylightObj = sceneRegistry.nodes.get(node.id)
|
||||
if (skylightObj) skylightObj.visible = false
|
||||
|
||||
const worldToBuildingLocal = (
|
||||
wx: number,
|
||||
wy: number,
|
||||
wz: number,
|
||||
): [number, number, number] => {
|
||||
const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => {
|
||||
const buildingId = useViewer.getState().selection.buildingId
|
||||
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
|
||||
if (buildingObj) {
|
||||
@@ -166,9 +158,7 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) {
|
||||
})
|
||||
|
||||
if (original.roofSegmentId && original.roofSegmentId !== (targetSegmentId as string)) {
|
||||
const oldSeg = st.nodes[original.roofSegmentId as AnyNodeId] as
|
||||
| RoofSegmentNode
|
||||
| undefined
|
||||
const oldSeg = st.nodes[original.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined
|
||||
if (oldSeg) {
|
||||
st.updateNode(original.roofSegmentId as AnyNodeId, {
|
||||
children: (oldSeg.children ?? []).filter((id) => id !== node.id),
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
SKYLIGHT_TYPE_ORDER,
|
||||
SKYLIGHT_TYPE_PRESETS,
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type RoofNode,
|
||||
type RoofSegmentNode,
|
||||
SKYLIGHT_TYPE_ORDER,
|
||||
SKYLIGHT_TYPE_PRESETS,
|
||||
type SkylightNode,
|
||||
type SkylightType,
|
||||
sceneRegistry,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { Vector3 } from 'three'
|
||||
import {
|
||||
ActionButton,
|
||||
ActionGroup,
|
||||
@@ -26,6 +25,7 @@ import {
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { useCallback } from 'react'
|
||||
import { Vector3 } from 'three'
|
||||
|
||||
const cn = (...classes: Array<string | false | undefined | null>): string =>
|
||||
classes.filter(Boolean).join(' ')
|
||||
|
||||
@@ -12,16 +12,19 @@ import {
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
type ColorPreset,
|
||||
createMaterial,
|
||||
createMaterialFromPresetRef,
|
||||
createSurfaceRoleMaterial,
|
||||
getRoofOuterSurfaceFrameAtPoint,
|
||||
useNodeEvents,
|
||||
useViewer,
|
||||
} from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { buildLanternGlassGeometry, clamp01, paneSize } from './geometry'
|
||||
import { buildFrameGeometry } from './frame-csg'
|
||||
import { surfaceQuatFromNormal } from '../solar-panel/geometry'
|
||||
import { buildFrameGeometry } from './frame-csg'
|
||||
import { buildLanternGlassGeometry, clamp01, paneSize } from './geometry'
|
||||
|
||||
const defaultFrameMaterial = new THREE.MeshStandardMaterial({
|
||||
color: 0xff_ff_ff,
|
||||
@@ -569,6 +572,10 @@ const SkylightRenderer = ({ node: storeNode }: { node: SkylightNode }) => {
|
||||
const ref = useRef<THREE.Group>(null!)
|
||||
useRegistry(storeNode.id, 'skylight', ref)
|
||||
const handlers = useNodeEvents(storeNode, 'skylight')
|
||||
const shading = useViewer((s) => s.shading)
|
||||
const textures = useViewer((s) => s.textures)
|
||||
const colorPreset: ColorPreset = useViewer((s) => s.colorPreset)
|
||||
const sceneTheme = useViewer((s) => s.sceneTheme)
|
||||
|
||||
const liveOverrides = useLiveNodeOverrides((state) => state.get(storeNode.id))
|
||||
const node = useMemo(
|
||||
@@ -599,13 +606,15 @@ const SkylightRenderer = ({ node: storeNode }: { node: SkylightNode }) => {
|
||||
}
|
||||
}, [frameGeo])
|
||||
|
||||
const frameMaterial = useMemo(
|
||||
() =>
|
||||
node.material
|
||||
? createMaterial(node.material)
|
||||
: (createMaterialFromPresetRef(node.materialPreset) ?? defaultFrameMaterial),
|
||||
[node.material, node.materialPreset],
|
||||
)
|
||||
const frameMaterial = useMemo(() => {
|
||||
// Untextured frame (and everything in textures-off mode) takes the
|
||||
// themed 'joinery' role colour; explicit paint shows when textures on.
|
||||
if (!textures || (!node.material && !node.materialPreset)) {
|
||||
return createSurfaceRoleMaterial('joinery', colorPreset, undefined, sceneTheme)
|
||||
}
|
||||
if (node.material) return createMaterial(node.material, shading)
|
||||
return createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultFrameMaterial
|
||||
}, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset])
|
||||
|
||||
const activeType = node.skylightType ?? 'flat'
|
||||
const typePreset = SKYLIGHT_TYPE_PRESETS[activeType]
|
||||
@@ -616,10 +625,16 @@ const SkylightRenderer = ({ node: storeNode }: { node: SkylightNode }) => {
|
||||
const openAmount = runtimeOpenAmount ?? node.operationState ?? typePreset.operationState
|
||||
|
||||
const glassMaterial = useMemo(() => {
|
||||
const mat =
|
||||
node.glassMaterial
|
||||
? createMaterial(node.glassMaterial)
|
||||
: (createMaterialFromPresetRef(node.glassMaterialPreset) ?? defaultGlassMaterial.clone())
|
||||
// Untextured glass (and textures-off mode) takes the themed 'glazing'
|
||||
// role material — already DoubleSide + semi-transparent, and shared
|
||||
// from the cache, so it must not be mutated.
|
||||
if (!textures || (!node.glassMaterial && !node.glassMaterialPreset)) {
|
||||
return createSurfaceRoleMaterial('glazing', colorPreset, undefined, sceneTheme)
|
||||
}
|
||||
const mat = node.glassMaterial
|
||||
? createMaterial(node.glassMaterial, shading)
|
||||
: (createMaterialFromPresetRef(node.glassMaterialPreset, shading) ??
|
||||
defaultGlassMaterial.clone())
|
||||
if (mat && typeof mat === 'object') {
|
||||
;(mat as THREE.Material).side = THREE.DoubleSide
|
||||
if (mat instanceof THREE.MeshPhysicalMaterial) {
|
||||
@@ -627,16 +642,19 @@ const SkylightRenderer = ({ node: storeNode }: { node: SkylightNode }) => {
|
||||
}
|
||||
}
|
||||
return mat
|
||||
}, [glassThickness, node.glassMaterial, node.glassMaterialPreset])
|
||||
}, [
|
||||
textures,
|
||||
colorPreset,
|
||||
sceneTheme,
|
||||
shading,
|
||||
glassThickness,
|
||||
node.glassMaterial,
|
||||
node.glassMaterialPreset,
|
||||
])
|
||||
|
||||
const surfaceFrame = useMemo(() => {
|
||||
if (!segment)
|
||||
return { point: new THREE.Vector3(), normal: new THREE.Vector3(0, 1, 0) }
|
||||
return getRoofOuterSurfaceFrameAtPoint(
|
||||
segment,
|
||||
node.position[0] ?? 0,
|
||||
node.position[2] ?? 0,
|
||||
)
|
||||
if (!segment) return { point: new THREE.Vector3(), normal: new THREE.Vector3(0, 1, 0) }
|
||||
return getRoofOuterSurfaceFrameAtPoint(segment, node.position[0] ?? 0, node.position[2] ?? 0)
|
||||
}, [segment, node.position[0], node.position[2], node.rotation, liveOverrides, storeNode.id])
|
||||
|
||||
const surfaceQuat = useMemo(
|
||||
@@ -662,51 +680,51 @@ const SkylightRenderer = ({ node: storeNode }: { node: SkylightNode }) => {
|
||||
<group position={[node.position[0] ?? 0, surfaceY, node.position[2] ?? 0]}>
|
||||
<group quaternion={surfaceQuat}>
|
||||
<group rotation-y={node.rotation ?? 0}>
|
||||
<mesh
|
||||
castShadow
|
||||
geometry={frameGeo}
|
||||
material={frameMaterial}
|
||||
name="skylight-surface"
|
||||
receiveShadow
|
||||
/>
|
||||
{activeType === 'lantern' && (
|
||||
<LanternGlass
|
||||
curbHeight={curbH}
|
||||
frameMaterial={frameMaterial}
|
||||
glassMaterial={glassMaterial}
|
||||
node={node}
|
||||
<mesh
|
||||
castShadow
|
||||
geometry={frameGeo}
|
||||
material={frameMaterial}
|
||||
name="skylight-surface"
|
||||
receiveShadow
|
||||
/>
|
||||
)}
|
||||
{activeType === 'sliding' && (
|
||||
<SlidingGlass
|
||||
curbHeight={curbH}
|
||||
frameMaterial={frameMaterial}
|
||||
glassMaterial={glassMaterial}
|
||||
glassThickness={glassThickness}
|
||||
node={node}
|
||||
openAmount={openAmount}
|
||||
/>
|
||||
)}
|
||||
{activeType === 'opening' && (
|
||||
<HingedGlass
|
||||
curbHeight={curbH}
|
||||
frameMaterial={frameMaterial}
|
||||
glassMaterial={glassMaterial}
|
||||
glassThickness={glassThickness}
|
||||
hasMotorHousing={node.motorHousing ?? false}
|
||||
node={node}
|
||||
openAmount={openAmount}
|
||||
/>
|
||||
)}
|
||||
{(activeType === 'flat' || activeType === 'walk-on') && (
|
||||
<GlassPane
|
||||
glassThickness={glassThickness}
|
||||
material={glassMaterial}
|
||||
paneDepth={node.height + 0.004}
|
||||
position={[0, curbH + glassThickness / 2, 0]}
|
||||
width={node.width + 0.004}
|
||||
/>
|
||||
)}
|
||||
{activeType === 'lantern' && (
|
||||
<LanternGlass
|
||||
curbHeight={curbH}
|
||||
frameMaterial={frameMaterial}
|
||||
glassMaterial={glassMaterial}
|
||||
node={node}
|
||||
/>
|
||||
)}
|
||||
{activeType === 'sliding' && (
|
||||
<SlidingGlass
|
||||
curbHeight={curbH}
|
||||
frameMaterial={frameMaterial}
|
||||
glassMaterial={glassMaterial}
|
||||
glassThickness={glassThickness}
|
||||
node={node}
|
||||
openAmount={openAmount}
|
||||
/>
|
||||
)}
|
||||
{activeType === 'opening' && (
|
||||
<HingedGlass
|
||||
curbHeight={curbH}
|
||||
frameMaterial={frameMaterial}
|
||||
glassMaterial={glassMaterial}
|
||||
glassThickness={glassThickness}
|
||||
hasMotorHousing={node.motorHousing ?? false}
|
||||
node={node}
|
||||
openAmount={openAmount}
|
||||
/>
|
||||
)}
|
||||
{(activeType === 'flat' || activeType === 'walk-on') && (
|
||||
<GlassPane
|
||||
glassThickness={glassThickness}
|
||||
material={glassMaterial}
|
||||
paneDepth={node.height + 0.004}
|
||||
position={[0, curbH + glassThickness / 2, 0]}
|
||||
width={node.width + 0.004}
|
||||
/>
|
||||
)}
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
emitter,
|
||||
type RoofEvent,
|
||||
type RoofNode,
|
||||
sceneRegistry,
|
||||
SkylightNode,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { triggerSFX } from '@pascal-app/editor'
|
||||
@@ -14,8 +14,8 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { resolveRoofSegmentHit } from '../roof/segment-hit'
|
||||
import { skylightDefinition } from './definition'
|
||||
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../solar-panel/geometry'
|
||||
import { skylightDefinition } from './definition'
|
||||
import SkylightPreview from './preview'
|
||||
|
||||
const worldPoint = new THREE.Vector3()
|
||||
@@ -43,11 +43,7 @@ const SkylightTool = () => {
|
||||
useEffect(() => {
|
||||
if (!activeBuildingId) return
|
||||
|
||||
const worldToBuildingLocal = (
|
||||
wx: number,
|
||||
wy: number,
|
||||
wz: number,
|
||||
): [number, number, number] => {
|
||||
const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => {
|
||||
const buildingObj = sceneRegistry.nodes.get(activeBuildingId as AnyNodeId)
|
||||
if (!buildingObj) return [wx, wy, wz]
|
||||
worldPoint.set(wx, wy, wz)
|
||||
|
||||
@@ -31,6 +31,7 @@ export const slabDefinition: NodeDefinition<typeof SlabNode> = {
|
||||
schemaVersion: 1,
|
||||
schema: SlabNode,
|
||||
category: 'structure',
|
||||
surfaceRole: 'floor',
|
||||
|
||||
defaults: () => ({
|
||||
object: 'node',
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { getMaterialPresetByRef, type SlabNode } from '@pascal-app/core'
|
||||
import {
|
||||
applyMaterialPresetToMaterials,
|
||||
type ColorPreset,
|
||||
createDefaultMaterial,
|
||||
createMaterial,
|
||||
createSurfaceRoleMaterial,
|
||||
DEFAULT_SLAB_MATERIAL,
|
||||
generateSlabGeometry,
|
||||
type RenderShading,
|
||||
} from '@pascal-app/viewer'
|
||||
import { DoubleSide, Group, Mesh, MeshStandardMaterial } from 'three'
|
||||
import { DoubleSide, Group, type Material, Mesh, type Texture } from 'three'
|
||||
|
||||
/**
|
||||
* Stage B builder for slab. Reuses `generateSlabGeometry` (pure
|
||||
@@ -17,10 +21,31 @@ import { DoubleSide, Group, Mesh, MeshStandardMaterial } from 'three'
|
||||
* (preset apply) is preserved — async texture loads still update the
|
||||
* rendered material after re-mount.
|
||||
*/
|
||||
const slabMaterialCache = new Map<string, MeshStandardMaterial>()
|
||||
type SlabMaterial = Material & {
|
||||
alphaMap?: Texture | null
|
||||
depthWrite: boolean
|
||||
opacity: number
|
||||
transparent: boolean
|
||||
}
|
||||
|
||||
const slabMaterialCache = new Map<string, Material>()
|
||||
|
||||
function getSlabMaterial(
|
||||
node: SlabNode,
|
||||
shading: RenderShading,
|
||||
textures: boolean,
|
||||
colorPreset: ColorPreset,
|
||||
sceneTheme?: string,
|
||||
): Material {
|
||||
// Untextured slabs (and everything in textures-off mode) take the themed
|
||||
// 'floor' role colour. createSurfaceRoleMaterial returns a shared cached
|
||||
// material, so it is returned as-is without the mutation below.
|
||||
if (!textures || (!node.materialPreset && !node.material)) {
|
||||
return createSurfaceRoleMaterial('floor', colorPreset, DoubleSide, sceneTheme)
|
||||
}
|
||||
|
||||
function getSlabMaterial(node: SlabNode): MeshStandardMaterial {
|
||||
const cacheKey = JSON.stringify({
|
||||
shading,
|
||||
material: node.material ?? null,
|
||||
materialPreset: node.materialPreset ?? null,
|
||||
})
|
||||
@@ -29,33 +54,43 @@ function getSlabMaterial(node: SlabNode): MeshStandardMaterial {
|
||||
|
||||
const preset = getMaterialPresetByRef(node.materialPreset)
|
||||
const material = preset
|
||||
? new MeshStandardMaterial()
|
||||
? createDefaultMaterial('#ffffff', 0.5, shading)
|
||||
: node.material
|
||||
? createMaterial(node.material).clone()
|
||||
: DEFAULT_SLAB_MATERIAL.clone()
|
||||
? createMaterial(node.material, shading).clone()
|
||||
: DEFAULT_SLAB_MATERIAL(shading).clone()
|
||||
|
||||
if (preset) {
|
||||
applyMaterialPresetToMaterials(material, preset)
|
||||
}
|
||||
|
||||
material.transparent = false
|
||||
material.opacity = 1
|
||||
material.alphaMap = null
|
||||
material.side = DoubleSide
|
||||
material.depthWrite = true
|
||||
material.needsUpdate = true
|
||||
const slabMaterial = material as SlabMaterial
|
||||
slabMaterial.transparent = false
|
||||
slabMaterial.opacity = 1
|
||||
slabMaterial.alphaMap = null
|
||||
slabMaterial.side = DoubleSide
|
||||
slabMaterial.depthWrite = true
|
||||
slabMaterial.needsUpdate = true
|
||||
|
||||
slabMaterialCache.set(cacheKey, material)
|
||||
return material
|
||||
}
|
||||
|
||||
export function buildSlabGeometry(node: SlabNode): Group {
|
||||
export function buildSlabGeometry(
|
||||
node: SlabNode,
|
||||
_ctx?: unknown,
|
||||
shading: RenderShading = 'rendered',
|
||||
textures = true,
|
||||
colorPreset: ColorPreset = 'clay',
|
||||
sceneTheme?: string,
|
||||
): Group {
|
||||
const group = new Group()
|
||||
const geometry = generateSlabGeometry(node)
|
||||
const material = getSlabMaterial(node)
|
||||
const material = getSlabMaterial(node, shading, textures, colorPreset, sceneTheme)
|
||||
const mesh = new Mesh(geometry, material)
|
||||
mesh.castShadow = true
|
||||
mesh.receiveShadow = true
|
||||
const elevation = node.elevation ?? 0.05
|
||||
if (elevation < 0) mesh.position.y = elevation
|
||||
group.add(mesh)
|
||||
return group
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export const solarPanelDefinition: NodeDefinition<typeof SolarPanelNode> = {
|
||||
schemaVersion: 1,
|
||||
schema: SolarPanelNode,
|
||||
category: 'structure',
|
||||
surfaceRole: 'roof',
|
||||
|
||||
defaults: () => {
|
||||
const stub = SolarPanelNodeSchema.parse({
|
||||
|
||||
@@ -106,8 +106,15 @@ export function getDefaultPanelMaterial(): THREE.Material {
|
||||
*/
|
||||
export function buildSolarPanelGeometry(node: SolarPanelNode): THREE.BufferGeometry | null {
|
||||
const {
|
||||
rows, columns, panelWidth, panelHeight, gapX, gapY,
|
||||
frameThickness, frameDepth, standoffHeight,
|
||||
rows,
|
||||
columns,
|
||||
panelWidth,
|
||||
panelHeight,
|
||||
gapX,
|
||||
gapY,
|
||||
frameThickness,
|
||||
frameDepth,
|
||||
standoffHeight,
|
||||
} = node
|
||||
|
||||
const frameGeos: THREE.BufferGeometry[] = []
|
||||
@@ -210,11 +217,7 @@ export function getSurfaceY(lx: number, lz: number, seg: RoofSegmentNode): numbe
|
||||
return peakY - t * rh
|
||||
}
|
||||
|
||||
export function getAnalyticalNormal(
|
||||
lx: number,
|
||||
lz: number,
|
||||
seg: RoofSegmentNode,
|
||||
): THREE.Vector3 {
|
||||
export function getAnalyticalNormal(lx: number, lz: number, seg: RoofSegmentNode): THREE.Vector3 {
|
||||
const { roofType, depth, width } = seg
|
||||
const rh = getActiveRoofHeight(seg)
|
||||
if (rh === 0) return new THREE.Vector3(0, 1, 0)
|
||||
|
||||
@@ -6,16 +6,11 @@ import {
|
||||
type RoofEvent,
|
||||
type RoofNode,
|
||||
type RoofSegmentNode,
|
||||
sceneRegistry,
|
||||
type SolarPanelNode,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
EDITOR_LAYER,
|
||||
markToolCancelConsumed,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
import { EDITOR_LAYER, markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
@@ -91,11 +86,7 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) {
|
||||
const panelObj = sceneRegistry.nodes.get(node.id)
|
||||
if (panelObj) panelObj.visible = false
|
||||
|
||||
const worldToBuildingLocal = (
|
||||
wx: number,
|
||||
wy: number,
|
||||
wz: number,
|
||||
): [number, number, number] => {
|
||||
const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => {
|
||||
const buildingId = useViewer.getState().selection.buildingId
|
||||
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
|
||||
if (buildingObj) {
|
||||
@@ -180,9 +171,7 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) {
|
||||
})
|
||||
|
||||
if (original.roofSegmentId && original.roofSegmentId !== (targetSegmentId as string)) {
|
||||
const oldSeg = st.nodes[original.roofSegmentId as AnyNodeId] as
|
||||
| RoofSegmentNode
|
||||
| undefined
|
||||
const oldSeg = st.nodes[original.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined
|
||||
if (oldSeg) {
|
||||
st.updateNode(original.roofSegmentId as AnyNodeId, {
|
||||
children: (oldSeg.children ?? []).filter((id) => id !== node.id),
|
||||
@@ -275,11 +264,7 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) {
|
||||
<group position={previewPos} ref={previewRef} visible={hasHit}>
|
||||
<group rotation-y={previewYaw}>
|
||||
<group quaternion={previewSurfaceQuat}>
|
||||
<mesh
|
||||
geometry={previewGeo}
|
||||
layers={EDITOR_LAYER}
|
||||
material={previewMaterial}
|
||||
/>
|
||||
<mesh geometry={previewGeo} layers={EDITOR_LAYER} material={previewMaterial} />
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user