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:
Wassim SAMAD
2026-05-22 17:33:00 -04:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 87384cfbab
commit bb5ce68254
140 changed files with 4036 additions and 1686 deletions
+1 -5
View File
@@ -33,11 +33,7 @@ export default function RootLayout({
> >
<head> <head>
{process.env.NODE_ENV === 'development' && ( {process.env.NODE_ENV === 'development' && (
<script <script async crossOrigin="anonymous" src="//unpkg.com/react-scan/dist/auto.global.js" />
async
crossOrigin="anonymous"
src="//unpkg.com/react-scan/dist/auto.global.js"
/>
)} )}
</head> </head>
<body className="font-sans"> <body className="font-sans">
+183 -22
View File
@@ -1,18 +1,36 @@
'use client' 'use client'
import { Icon as IconifyIcon } from '@iconify/react' import { Icon as IconifyIcon } from '@iconify/react'
import { useEditor, useSidebarStore, type ViewMode } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
useEditor,
useSidebarStore,
type ViewMode,
} from '@pascal-app/editor'
import {
CLAY_PALETTE,
type EdgeMode,
getSceneTheme,
SCENE_THEMES,
useViewer,
} from '@pascal-app/viewer'
import {
Box,
Check,
ChevronsLeft, ChevronsLeft,
ChevronsRight, ChevronsRight,
Columns2, Columns2,
Contrast,
Eye, Eye,
EyeOff, EyeOff,
Footprints, Footprints,
Grid2X2, Grid2X2,
Moon, PenLine,
Sun, Sparkles,
SwatchBook,
} from 'lucide-react' } from 'lucide-react'
import Image from 'next/image' import Image from 'next/image'
import { type ReactNode, useCallback } from 'react' import { type ReactNode, useCallback } from 'react'
@@ -83,6 +101,11 @@ const wallModeConfig: Record<string, { icon: string; label: string }> = {
down: { icon: '/icons/walllow.png', label: 'Low' }, down: { icon: '/icons/walllow.png', label: 'Low' },
} }
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 ViewModeControl() { function ViewModeControl() {
const viewMode = useEditor((state) => state.viewMode) const viewMode = useEditor((state) => state.viewMode)
const setViewMode = useEditor((state) => state.setViewMode) const setViewMode = useEditor((state) => state.setViewMode)
@@ -216,6 +239,134 @@ function WallModeToggle() {
) )
} }
function RenderModeMenu() {
const shading = useViewer((state) => state.shading)
const setShading = useViewer((state) => state.setShading)
const active = SHADING_OPTIONS.find((option) => option.id === shading) ?? SHADING_OPTIONS[0]
const ActiveIcon = active.icon
return (
<DropdownMenu>
<ToolbarTooltip label={`Render: ${active.name}`}>
<DropdownMenuTrigger asChild>
<button
aria-label={`Render: ${active.name}`}
className={cn(
TOOLBAR_BTN,
'w-auto gap-1.5 px-2.5',
shading === 'rendered' && 'bg-white/10 text-foreground/90',
)}
type="button"
>
<ActiveIcon className="h-3.5 w-3.5" />
<span className="font-medium text-xs">{active.name}</span>
</button>
</DropdownMenuTrigger>
</ToolbarTooltip>
<DropdownMenuContent align="center" className="min-w-56" side="bottom">
{SHADING_OPTIONS.map((option) => {
const OptionIcon = option.icon
return (
<DropdownMenuItem key={option.id} onSelect={() => setShading(option.id)}>
<OptionIcon className="h-4 w-4" />
<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 h-4 w-4 text-foreground" /> : null}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
}
function SceneThemeMenu() {
const sceneTheme = useViewer((state) => state.sceneTheme)
const setSceneTheme = useViewer((state) => state.setSceneTheme)
const active = getSceneTheme(sceneTheme)
return (
<DropdownMenu>
<ToolbarTooltip label={`Scene theme: ${active.name}`}>
<DropdownMenuTrigger asChild>
<button
aria-label={`Scene theme: ${active.name}`}
className={cn(TOOLBAR_BTN, 'w-28 gap-1.5 px-2.5 text-foreground/90')}
type="button"
>
<SwatchBook className="h-3.5 w-3.5 shrink-0" />
<span className="truncate font-medium text-xs">{active.name}</span>
</button>
</DropdownMenuTrigger>
</ToolbarTooltip>
<DropdownMenuContent align="center" className="min-w-48" side="bottom">
{SCENE_THEMES.map((theme) => {
const swatches = (['wall', 'roof', 'floor', 'glazing'] as const).map(
(role) => theme.clayTints?.[role] ?? CLAY_PALETTE[role],
)
return (
<DropdownMenuItem key={theme.id} onSelect={() => setSceneTheme(theme.id)}>
<span
className="grid h-5 w-5 shrink-0 grid-cols-2 overflow-hidden rounded-sm border border-black/10"
style={{ backgroundColor: theme.background }}
>
{swatches.map((color, index) => (
<span key={`${theme.id}-${index}`} style={{ backgroundColor: color }} />
))}
</span>
<span className="text-foreground">{theme.name}</span>
{sceneTheme === theme.id ? (
<Check className="ml-auto h-4 w-4 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((state) => state.edges)
const setEdges = useViewer((state) => state.setEdges)
const active = EDGE_OPTIONS.find((option) => option.id === edges) ?? EDGE_OPTIONS[0]
return (
<DropdownMenu>
<ToolbarTooltip label={`Edges: ${active.name}`}>
<DropdownMenuTrigger asChild>
<button
aria-label={`Edges: ${active.name}`}
className={cn(TOOLBAR_BTN, edges !== 'off' && 'bg-white/10 text-foreground/90')}
type="button"
>
<PenLine className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
</ToolbarTooltip>
<DropdownMenuContent align="center" className="min-w-56" side="bottom">
{EDGE_OPTIONS.map((option) => (
<DropdownMenuItem key={option.id} onSelect={() => 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 h-4 w-4 text-foreground" /> : null}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
function GridVisibilityToggle() { function GridVisibilityToggle() {
const showGrid = useViewer((state) => state.showGrid) const showGrid = useViewer((state) => state.showGrid)
const setShowGrid = useViewer((state) => state.setShowGrid) const setShowGrid = useViewer((state) => state.setShowGrid)
@@ -242,6 +393,30 @@ function GridVisibilityToggle() {
) )
} }
function ShadowsToggle() {
const shadows = useViewer((state) => state.shadows)
const setShadows = useViewer((state) => state.setShadows)
return (
<ToolbarTooltip label={`Shadows: ${shadows ? 'On' : 'Off'}`}>
<button
aria-label={`Shadows: ${shadows ? 'On' : 'Off'}`}
aria-pressed={shadows}
className={cn(
TOOLBAR_BTN,
shadows
? 'bg-white/10 text-foreground/90'
: 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0',
)}
onClick={() => setShadows(!shadows)}
type="button"
>
<Contrast className="h-3.5 w-3.5" />
</button>
</ToolbarTooltip>
)
}
function UnitToggle() { function UnitToggle() {
const unit = useViewer((state) => state.unit) const unit = useViewer((state) => state.unit)
const setUnit = useViewer((state) => state.setUnit) const setUnit = useViewer((state) => state.setUnit)
@@ -259,23 +434,6 @@ function UnitToggle() {
) )
} }
function ThemeToggle() {
const theme = useViewer((state) => state.theme)
const setTheme = useViewer((state) => state.setTheme)
return (
<ToolbarTooltip label={theme === 'dark' ? 'Dark' : 'Light'}>
<button
className={cn(TOOLBAR_BTN, theme === 'dark' ? 'text-indigo-400/70' : 'text-amber-400/70')}
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
type="button"
>
{theme === 'dark' ? <Moon className="h-3.5 w-3.5" /> : <Sun className="h-3.5 w-3.5" />}
</button>
</ToolbarTooltip>
)
}
function CameraModeToggle() { function CameraModeToggle() {
const cameraMode = useViewer((state) => state.cameraMode) const cameraMode = useViewer((state) => state.cameraMode)
const setCameraMode = useViewer((state) => state.setCameraMode) const setCameraMode = useViewer((state) => state.setCameraMode)
@@ -349,10 +507,13 @@ export function CommunityViewerToolbarRight() {
<div className={TOOLBAR_CONTAINER}> <div className={TOOLBAR_CONTAINER}>
<LevelModeToggle /> <LevelModeToggle />
<WallModeToggle /> <WallModeToggle />
<RenderModeMenu />
<SceneThemeMenu />
<EdgesMenu />
<GridVisibilityToggle /> <GridVisibilityToggle />
<ShadowsToggle />
<div className="my-1.5 w-px bg-border/50" /> <div className="my-1.5 w-px bg-border/50" />
<UnitToggle /> <UnitToggle />
<ThemeToggle />
<CameraModeToggle /> <CameraModeToggle />
<div className="my-1.5 w-px bg-border/50" /> <div className="my-1.5 w-px bg-border/50" />
<WalkthroughButton /> <WalkthroughButton />
@@ -10,7 +10,7 @@
import { type AnyNode, type LevelNode, useScene } from '@pascal-app/core' import { type AnyNode, type LevelNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Box, Grid2x2, Layers, Layers2, Maximize, Moon, ScanLine, Square, Sun } from 'lucide-react' import { Box, Grid2x2, Layers, Layers2, Maximize, ScanLine, Square } from 'lucide-react'
import { type ReactNode, useMemo } from 'react' import { type ReactNode, useMemo } from 'react'
const levelModes = ['stacked', 'solo', 'exploded', 'manual'] as const const levelModes = ['stacked', 'solo', 'exploded', 'manual'] as const
@@ -65,8 +65,6 @@ function ToolButton({
export function PreviewToolbar() { export function PreviewToolbar() {
const cameraMode = useViewer((s) => s.cameraMode) const cameraMode = useViewer((s) => s.cameraMode)
const setCameraMode = useViewer((s) => s.setCameraMode) const setCameraMode = useViewer((s) => s.setCameraMode)
const theme = useViewer((s) => s.theme)
const setTheme = useViewer((s) => s.setTheme)
const showGrid = useViewer((s) => s.showGrid) const showGrid = useViewer((s) => s.showGrid)
const setShowGrid = useViewer((s) => s.setShowGrid) const setShowGrid = useViewer((s) => s.setShowGrid)
const levelMode = useViewer((s) => s.levelMode) const levelMode = useViewer((s) => s.levelMode)
@@ -115,13 +113,6 @@ export function PreviewToolbar() {
label="Grid" label="Grid"
onClick={() => setShowGrid(!showGrid)} onClick={() => setShowGrid(!showGrid)}
/> />
<ToolButton
active={theme === 'dark'}
icon={theme === 'dark' ? <Moon className="size-3.5" /> : <Sun className="size-3.5" />}
label={theme === 'dark' ? 'Dark' : 'Light'}
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
/>
</div> </div>
) )
} }
+2 -2
View File
@@ -6,9 +6,9 @@ import type {
BuildingNode, BuildingNode,
CeilingNode, CeilingNode,
ChimneyNode, ChimneyNode,
DormerNode,
ColumnNode, ColumnNode,
DoorNode, DoorNode,
DormerNode,
ElevatorNode, ElevatorNode,
FenceNode, FenceNode,
GuideNode, GuideNode,
@@ -21,8 +21,8 @@ import type {
ShelfNode, ShelfNode,
SiteNode, SiteNode,
SkylightNode, SkylightNode,
SolarPanelNode,
SlabNode, SlabNode,
SolarPanelNode,
SpawnNode, SpawnNode,
StairNode, StairNode,
StairSegmentNode, StairSegmentNode,
+1
View File
@@ -70,6 +70,7 @@ export type {
SnapServicesLike, SnapServicesLike,
SurfacePoint, SurfacePoint,
SurfaceQuery, SurfaceQuery,
SurfaceRole,
SurfacesConfig, SurfacesConfig,
SystemContribution, SystemContribution,
ToolHint, ToolHint,
+10
View File
@@ -512,11 +512,21 @@ export type Plugin = {
export type AnyNodeDefinition = NodeDefinition<ZodObject<any>> export type AnyNodeDefinition = NodeDefinition<ZodObject<any>>
export type SurfaceRole =
| 'wall'
| 'floor'
| 'ceiling'
| 'roof'
| 'joinery'
| 'glazing'
| 'furnishing'
export type NodeDefinition<S extends ZodObject<any>> = { export type NodeDefinition<S extends ZodObject<any>> = {
kind: string kind: string
schemaVersion: number schemaVersion: number
schema: S schema: S
category: NodeCategory category: NodeCategory
surfaceRole?: SurfaceRole
defaults: () => Omit<z.infer<S>, 'id' | 'type'> defaults: () => Omit<z.infer<S>, 'id' | 'type'>
migrate?: Record<number, (old: unknown) => unknown> migrate?: Record<number, (old: unknown) => unknown>
+18 -17
View File
@@ -1,4 +1,11 @@
// Base // Base
export {
SOLAR_PANEL_PRESET_LABELS,
SOLAR_PANEL_PRESETS,
type SolarPanelPresetDims,
SolarPanelPresetKey,
} from '../solar-panel-presets'
export { BaseNode, generateId, Material, nodeType, objectId } from './base' export { BaseNode, generateId, Material, nodeType, objectId } from './base'
// Camera // Camera
export { CameraSchema } from './camera' export { CameraSchema } from './camera'
@@ -28,12 +35,6 @@ export { BoxVentNode } from './nodes/box-vent'
export { BuildingNode } from './nodes/building' export { BuildingNode } from './nodes/building'
export { CeilingNode } from './nodes/ceiling' export { CeilingNode } from './nodes/ceiling'
export { ChimneyMaterialRole, ChimneyNode } from './nodes/chimney' export { ChimneyMaterialRole, ChimneyNode } from './nodes/chimney'
export {
DormerNode,
type DormerSurfaceMaterialRole,
type DormerSurfaceMaterialSpec,
getEffectiveDormerSurfaceMaterial,
} from './nodes/dormer'
export { export {
COLUMN_PRESETS, COLUMN_PRESETS,
ColumnBaseStyle, ColumnBaseStyle,
@@ -50,6 +51,12 @@ export {
ColumnSupportStyle, ColumnSupportStyle,
} from './nodes/column' } from './nodes/column'
export { DoorNode, DoorSegment } from './nodes/door' export { DoorNode, DoorSegment } from './nodes/door'
export {
DormerNode,
type DormerSurfaceMaterialRole,
type DormerSurfaceMaterialSpec,
getEffectiveDormerSurfaceMaterial,
} from './nodes/dormer'
export { export {
ElevatorDoorPanelStyle, ElevatorDoorPanelStyle,
ElevatorDoorStyle, ElevatorDoorStyle,
@@ -77,6 +84,8 @@ export {
LOW_PROFILE_ITEM_SURFACE_MAX_HEIGHT, LOW_PROFILE_ITEM_SURFACE_MAX_HEIGHT,
} from './nodes/item' } from './nodes/item'
export { LevelNode } from './nodes/level' export { LevelNode } from './nodes/level'
// Nodes
export { RidgeVentNode } from './nodes/ridge-vent'
export type { RoofSurfaceMaterialRole, RoofSurfaceMaterialSpec } from './nodes/roof' export type { RoofSurfaceMaterialRole, RoofSurfaceMaterialSpec } from './nodes/roof'
export { getEffectiveRoofSurfaceMaterial, RoofNode } from './nodes/roof' export { getEffectiveRoofSurfaceMaterial, RoofNode } from './nodes/roof'
export type { export type {
@@ -95,9 +104,8 @@ export {
RoofType, RoofType,
} from './nodes/roof-segment' } from './nodes/roof-segment'
export { ScanNode } from './nodes/scan' export { ScanNode } from './nodes/scan'
// Nodes
export { RidgeVentNode } from './nodes/ridge-vent'
export { ShelfNode } from './nodes/shelf' export { ShelfNode } from './nodes/shelf'
export { SiteNode } from './nodes/site'
export { export {
SKYLIGHT_TYPE_ORDER, SKYLIGHT_TYPE_ORDER,
SKYLIGHT_TYPE_PRESETS, SKYLIGHT_TYPE_PRESETS,
@@ -105,21 +113,14 @@ export {
SkylightNode, SkylightNode,
SkylightOpeningSide, SkylightOpeningSide,
SkylightSlideDirection, SkylightSlideDirection,
type SkylightTypePreset,
SkylightType, SkylightType,
type SkylightTypePreset,
} from './nodes/skylight' } from './nodes/skylight'
export { SlabNode } from './nodes/slab'
export { export {
SolarPanelMaterialRole, SolarPanelMaterialRole,
SolarPanelNode, SolarPanelNode,
} from './nodes/solar-panel' } 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 { SpawnNode } from './nodes/spawn'
export type { StairSurfaceMaterialRole, StairSurfaceMaterialSpec } from './nodes/stair' export type { StairSurfaceMaterialRole, StairSurfaceMaterialSpec } from './nodes/stair'
export { export {
+9 -11
View File
@@ -164,8 +164,7 @@ function withRatioDefaults(input: PitchInputs): PitchInputs & ShapeRatios {
mansardSteepHeightRatio: mansardSteepHeightRatio:
input.mansardSteepHeightRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepHeightRatio, input.mansardSteepHeightRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepHeightRatio,
dutchHipWidthRatio: input.dutchHipWidthRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio, dutchHipWidthRatio: input.dutchHipWidthRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio,
dutchHipHeightRatio: dutchHipHeightRatio: input.dutchHipHeightRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipHeightRatio,
input.dutchHipHeightRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipHeightRatio,
} }
} }
@@ -226,12 +225,15 @@ export type SegmentSlopeFrame = {
* silently drifted when a new roof type was added. * silently drifted when a new roof type was added.
*/ */
export function getSegmentSlopeFrame( export function getSegmentSlopeFrame(
node: Pick<RoofSegmentNode, 'roofType' | 'pitch' | 'width' | 'depth'> & node: Pick<RoofSegmentNode, 'roofType' | 'pitch' | 'width' | 'depth'> & Partial<ShapeRatios>,
Partial<ShapeRatios>,
): SegmentSlopeFrame { ): SegmentSlopeFrame {
const ratios = withRatioDefaults(node) const ratios = withRatioDefaults(node)
const run = getPrimarySlopeRun(ratios) 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 } return { run, rise: 0, tanTheta: 0, cosTheta: 1, sinTheta: 0, activeRh: 0 }
} }
const pitchRad = (node.pitch * Math.PI) / 180 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 + * The eave-to-peak height of the assembled segment, derived from pitch +
* footprint + roofType. Replaces the legacy `roofHeight` field on the node. * footprint + roofType. Replaces the legacy `roofHeight` field on the node.
*/ */
export function getActiveRoofHeight( export function getActiveRoofHeight(node: Parameters<typeof getSegmentSlopeFrame>[0]): number {
node: Parameters<typeof getSegmentSlopeFrame>[0],
): number {
return getSegmentSlopeFrame(node).activeRh return getSegmentSlopeFrame(node).activeRh
} }
@@ -258,9 +258,7 @@ export function getActiveRoofHeight(
* `roofHeight` value would correspond to. Used by the scene migration. * `roofHeight` value would correspond to. Used by the scene migration.
* Ratio overrides are optional and default to the shape defaults. * Ratio overrides are optional and default to the shape defaults.
*/ */
export function getPitchFromActiveRoofHeight( export function getPitchFromActiveRoofHeight(input: PitchInputs & { roofHeight: number }): number {
input: PitchInputs & { roofHeight: number },
): number {
if (input.roofType === 'flat' || input.roofHeight <= 0) return 0 if (input.roofType === 'flat' || input.roofHeight <= 0) return 0
const ratios = withRatioDefaults(input) const ratios = withRatioDefaults(input)
const run = getPrimarySlopeRun(ratios) const run = getPrimarySlopeRun(ratios)
+90 -25
View File
@@ -42,43 +42,108 @@ export type SkylightTypePreset = {
export const SKYLIGHT_TYPE_PRESETS = { export const SKYLIGHT_TYPE_PRESETS = {
flat: { flat: {
label: 'Flat roof skylight', label: 'Flat roof skylight',
width: 0.9, height: 1.2, frameThickness: 0.055, frameDepth: 0.08, width: 0.9,
glassThickness: 0.012, curb: true, curbHeight: 0.08, cutoutOffset: 0, height: 1.2,
lanternHeight: 0.25, lanternTopScale: 0, openingAngle: 0, frameThickness: 0.055,
openingSide: 'top', operationState: 0, motorHousing: false, frameDepth: 0.08,
slideFraction: 0, slideDirection: 'z', trackWidth: 0.04, motorHousingSize: 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': { 'walk-on': {
label: 'Walk-on rooflight', label: 'Walk-on rooflight',
width: 1.2, height: 1.8, frameThickness: 0.035, frameDepth: 0.045, width: 1.2,
glassThickness: 0.04, curb: false, curbHeight: 0, cutoutOffset: 0, height: 1.8,
lanternHeight: 0.25, lanternTopScale: 0, openingAngle: 0, frameThickness: 0.035,
openingSide: 'top', operationState: 0, motorHousing: false, frameDepth: 0.045,
slideFraction: 0, slideDirection: 'z', trackWidth: 0.04, motorHousingSize: 0.08, 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: { lantern: {
label: 'Roof lantern', label: 'Roof lantern',
width: 1.4, height: 1.4, frameThickness: 0.06, frameDepth: 0.08, width: 1.4,
glassThickness: 0.012, curb: true, curbHeight: 0.16, cutoutOffset: 0, height: 1.4,
lanternHeight: 0.45, lanternTopScale: 0, openingAngle: 0, frameThickness: 0.06,
openingSide: 'top', operationState: 0, motorHousing: false, frameDepth: 0.08,
slideFraction: 0, slideDirection: 'z', trackWidth: 0.04, motorHousingSize: 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: { opening: {
label: 'Opening skylight', label: 'Opening skylight',
width: 0.9, height: 1.2, frameThickness: 0.06, frameDepth: 0.035, width: 0.9,
glassThickness: 0.014, curb: true, curbHeight: 0.1, cutoutOffset: 0, height: 1.2,
lanternHeight: 0.25, lanternTopScale: 0, openingAngle: Math.PI / 8, frameThickness: 0.06,
openingSide: 'top', operationState: 1, motorHousing: false, frameDepth: 0.035,
slideFraction: 0, slideDirection: 'z', trackWidth: 0.04, motorHousingSize: 0.08, 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: { sliding: {
label: 'Sliding skylight', label: 'Sliding skylight',
width: 1.4, height: 1.1, frameThickness: 0.055, frameDepth: 0.08, width: 1.4,
glassThickness: 0.012, curb: true, curbHeight: 0.08, cutoutOffset: 0, height: 1.1,
lanternHeight: 0.25, lanternTopScale: 0, openingAngle: 0, frameThickness: 0.055,
openingSide: 'top', operationState: 0.35, motorHousing: false, frameDepth: 0.08,
slideFraction: 0.35, slideDirection: 'x', trackWidth: 0.045, motorHousingSize: 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> } as const satisfies Record<SkylightType, SkylightTypePreset>
+3 -3
View File
@@ -3,9 +3,9 @@ import { BoxVentNode } from './nodes/box-vent'
import { BuildingNode } from './nodes/building' import { BuildingNode } from './nodes/building'
import { CeilingNode } from './nodes/ceiling' import { CeilingNode } from './nodes/ceiling'
import { ChimneyNode } from './nodes/chimney' import { ChimneyNode } from './nodes/chimney'
import { DormerNode } from './nodes/dormer'
import { ColumnNode } from './nodes/column' import { ColumnNode } from './nodes/column'
import { DoorNode } from './nodes/door' import { DoorNode } from './nodes/door'
import { DormerNode } from './nodes/dormer'
import { ElevatorNode } from './nodes/elevator' import { ElevatorNode } from './nodes/elevator'
import { FenceNode } from './nodes/fence' import { FenceNode } from './nodes/fence'
import { GuideNode } from './nodes/guide' import { GuideNode } from './nodes/guide'
@@ -16,10 +16,10 @@ import { RoofNode } from './nodes/roof'
import { RoofSegmentNode } from './nodes/roof-segment' import { RoofSegmentNode } from './nodes/roof-segment'
import { ScanNode } from './nodes/scan' import { ScanNode } from './nodes/scan'
import { ShelfNode } from './nodes/shelf' import { ShelfNode } from './nodes/shelf'
import { SkylightNode } from './nodes/skylight'
import { SolarPanelNode } from './nodes/solar-panel'
import { SiteNode } from './nodes/site' import { SiteNode } from './nodes/site'
import { SkylightNode } from './nodes/skylight'
import { SlabNode } from './nodes/slab' import { SlabNode } from './nodes/slab'
import { SolarPanelNode } from './nodes/solar-panel'
import { SpawnNode } from './nodes/spawn' import { SpawnNode } from './nodes/spawn'
import { StairNode } from './nodes/stair' import { StairNode } from './nodes/stair'
import { StairSegmentNode } from './nodes/stair-segment' import { StairSegmentNode } from './nodes/stair-segment'
@@ -97,8 +97,7 @@ function vent(): AnyNode {
describe('node-actions reparent — repeated segment-hopping', () => { describe('node-actions reparent — repeated segment-hopping', () => {
beforeEach(() => { beforeEach(() => {
useScene.setState( useScene.setState({
{
nodes: { nodes: {
[ROOF_ID]: makeRoof(), [ROOF_ID]: makeRoof(),
[SEG_A_ID]: makeSegment(SEG_A_ID, [VENT_ID]), [SEG_A_ID]: makeSegment(SEG_A_ID, [VENT_ID]),
@@ -106,8 +105,7 @@ describe('node-actions reparent — repeated segment-hopping', () => {
[VENT_ID]: makeVent(SEG_A_ID), [VENT_ID]: makeVent(SEG_A_ID),
}, },
rootNodeIds: [ROOF_ID], rootNodeIds: [ROOF_ID],
} as never, } as never)
)
useScene.temporal.getState().clear() useScene.temporal.getState().clear()
}) })
@@ -166,12 +164,9 @@ describe('node-actions reparent — repeated segment-hopping', () => {
['dormer', 'dorm_x'], ['dormer', 'dorm_x'],
['solar-panel', 'sp_x'], ['solar-panel', 'sp_x'],
['ridge-vent', 'rvent_x'], ['ridge-vent', 'rvent_x'],
])( ])('A→B→A→B for %s: chimney-style auto-reparent leaves children clean', (type, idStr) => {
'A→B→A→B for %s: chimney-style auto-reparent leaves children clean',
(type, idStr) => {
const id = idStr as AnyNodeId const id = idStr as AnyNodeId
useScene.setState( useScene.setState({
{
nodes: { nodes: {
[ROOF_ID]: makeRoof(), [ROOF_ID]: makeRoof(),
[SEG_A_ID]: makeSegment(SEG_A_ID, [id]), [SEG_A_ID]: makeSegment(SEG_A_ID, [id]),
@@ -190,13 +185,10 @@ describe('node-actions reparent — repeated segment-hopping', () => {
} as unknown as AnyNode, } as unknown as AnyNode,
}, },
rootNodeIds: [ROOF_ID], rootNodeIds: [ROOF_ID],
} as never, } as never)
)
const hop = (to: AnyNodeId) => const hop = (to: AnyNodeId) =>
useScene useScene.getState().updateNode(id, { parentId: to, roofSegmentId: to } as Partial<AnyNode>)
.getState()
.updateNode(id, { parentId: to, roofSegmentId: to } as Partial<AnyNode>)
hop(SEG_B_ID) hop(SEG_B_ID)
hop(SEG_A_ID) hop(SEG_A_ID)
@@ -206,8 +198,7 @@ describe('node-actions reparent — repeated segment-hopping', () => {
expect(childrenOf(SEG_B_ID)).toEqual([id]) expect(childrenOf(SEG_B_ID)).toEqual([id])
const node = useScene.getState().nodes[id] as { parentId: AnyNodeId } const node = useScene.getState().nodes[id] as { parentId: AnyNodeId }
expect(node.parentId).toBe(SEG_B_ID) expect(node.parentId).toBe(SEG_B_ID)
}, })
)
test('A→B→A→B with redundant manual children edits (vent move-tool pattern, pre-fix)', () => { 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 // Reproduces the box-vent / ridge-vent move-tool order: manual
+24 -15
View File
@@ -9,6 +9,7 @@ import { generateCollectionId } from '../schema/collections'
import { LevelNode } from '../schema/nodes/level' import { LevelNode } from '../schema/nodes/level'
import { import {
getPitchFromActiveRoofHeight, getPitchFromActiveRoofHeight,
type RoofSegmentNode,
type RoofType, type RoofType,
} from '../schema/nodes/roof-segment' } from '../schema/nodes/roof-segment'
import { SiteNode } from '../schema/nodes/site' import { SiteNode } from '../schema/nodes/site'
@@ -316,7 +317,10 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
roofType: 'gable', roofType: 'gable',
width: segWidth, width: segWidth,
depth: segDepth, 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({ pitch: getPitchFromActiveRoofHeight({
roofType: 'gable', roofType: 'gable',
width: segWidth, width: segWidth,
@@ -336,23 +340,28 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
} }
} }
// 2b. roof-segment: legacy roofHeight → pitch. // 2b. roof-segment: guarantee a valid positive pitch (degrees).
// Saved scenes wrote `roofHeight` in metres; the schema now stores // Saved scenes wrote `roofHeight` in metres; the schema now stores `pitch`
// `pitch` in degrees. Convert once and drop the old field so future // in degrees. Convert the legacy field when present, and — crucially — fall
// loads skip this branch. // back to the schema default for any segment that carries no usable pitch or
if (node.type === 'roof-segment' && 'roofHeight' in node && !('pitch' in node)) { // roofHeight (older/partial saves). Without this, the slope-frame guard
const { roofHeight, ...rest } = node // 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 width = typeof node.width === 'number' ? node.width : 8
const depth = typeof node.depth === 'number' ? node.depth : 6 const depth = typeof node.depth === 'number' ? node.depth : 6
const roofType = (typeof node.roofType === 'string' ? node.roofType : 'gable') as RoofType const roofType = (typeof node.roofType === 'string' ? node.roofType : 'gable') as RoofType
patchedNodes[id] = { const derived =
...rest, typeof roofHeight === 'number' && roofHeight > 0
pitch: getPitchFromActiveRoofHeight({ ? getPitchFromActiveRoofHeight({ roofType, width, depth, roofHeight })
roofType, : 0
width, // 40° matches the RoofSegmentNode schema default.
depth, patchedNodes[id] = { ...rest, pitch: derived > 0 ? derived : 40 }
roofHeight: typeof roofHeight === 'number' ? roofHeight : 2.5,
}),
} }
} }
@@ -7,7 +7,7 @@ import {
sceneRegistry, sceneRegistry,
useScene, useScene,
} from '@pascal-app/core' } 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 { CameraControls, CameraControlsImpl } from '@react-three/drei'
import { useThree } from '@react-three/fiber' import { useThree } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef } from 'react' import { useCallback, useEffect, useMemo, useRef } from 'react'
@@ -40,6 +40,7 @@ export const CustomCameraControls = () => {
const raycaster = useThree((state) => state.raycaster) const raycaster = useThree((state) => state.raycaster)
useEffect(() => { useEffect(() => {
camera.layers.enable(EDITOR_LAYER) camera.layers.enable(EDITOR_LAYER)
camera.layers.enable(GRID_LAYER)
raycaster.layers.enable(EDITOR_LAYER) raycaster.layers.enable(EDITOR_LAYER)
raycaster.layers.enable(ZONE_LAYER) raycaster.layers.enable(ZONE_LAYER)
}, [camera, raycaster]) }, [camera, raycaster])
@@ -1,6 +1,6 @@
'use client' '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 { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
import { MobileTabBar } from '../ui/sidebar/mobile-tab-bar' import { MobileTabBar } from '../ui/sidebar/mobile-tab-bar'
@@ -56,8 +56,8 @@ export function EditorLayoutMobile({
const activePanel = useEditor((s) => s.activeSidebarPanel) const activePanel = useEditor((s) => s.activeSidebarPanel)
const setActivePanel = useEditor((s) => s.setActiveSidebarPanel) const setActivePanel = useEditor((s) => s.setActiveSidebarPanel)
const panelSheetHeight = useEditor((s) => s.mobilePanelSheetHeight) const panelSheetHeight = useEditor((s) => s.mobilePanelSheetHeight)
const theme = useViewer((s) => s.theme) const isDark = useViewer((s) => getSceneTheme(s.sceneTheme).appearance === 'dark')
const viewerBg = theme === 'light' ? VIEWER_BG_LIGHT : VIEWER_BG_DARK const viewerBg = isDark ? VIEWER_BG_DARK : VIEWER_BG_LIGHT
const middleRef = useRef<HTMLDivElement>(null) const middleRef = useRef<HTMLDivElement>(null)
const sheetRef = useRef<BottomSheetHandle>(null) const sheetRef = useRef<BottomSheetHandle>(null)
@@ -45,7 +45,7 @@ import {
ZoneNode as ZoneNodeSchema, ZoneNode as ZoneNodeSchema,
type ZoneNode as ZoneNodeType, type ZoneNode as ZoneNodeType,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { getSceneTheme, useViewer } from '@pascal-app/viewer'
import { Command, Ruler } from 'lucide-react' import { Command, Ruler } from 'lucide-react'
import { import {
memo, memo,
@@ -3896,7 +3896,7 @@ export function FloorplanPanel() {
const previewSelectedIds = useViewer((state) => state.previewSelectedIds) const previewSelectedIds = useViewer((state) => state.previewSelectedIds)
const setSelection = useViewer((state) => state.setSelection) const setSelection = useViewer((state) => state.setSelection)
const setPreviewSelectedIds = useViewer((state) => state.setPreviewSelectedIds) 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 unit = useViewer((state) => state.unit)
const showGrid = useViewer((state) => state.showGrid) const showGrid = useViewer((state) => state.showGrid)
const showGuides = useViewer((state) => state.showGuides) const showGuides = useViewer((state) => state.showGuides)
@@ -5234,7 +5234,7 @@ export function FloorplanPanel() {
const palette = useMemo( const palette = useMemo(
() => () =>
theme === 'dark' isDark
? { ? {
surface: '#0a0e1b', surface: '#0a0e1b',
minorGrid: '#334155', minorGrid: '#334155',
@@ -5347,9 +5347,9 @@ export function FloorplanPanel() {
curveHandleStroke: '#0f766e', curveHandleStroke: '#0f766e',
curveHandleHoverStroke: '#14b8a6', 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 // Subset of the legacy palette surfaced to registry-driven kinds via
// <FloorplanRenderProvider>. Mirrors `FloorplanPalette` in `@pascal-app/ // <FloorplanRenderProvider>. Mirrors `FloorplanPalette` in `@pascal-app/
// core` — keep slot names + meanings in sync. // core` — keep slot names + meanings in sync.
@@ -5368,12 +5368,12 @@ export function FloorplanPanel() {
curveHandleStroke: palette.curveHandleStroke, curveHandleStroke: palette.curveHandleStroke,
curveHandleHoverStroke: palette.curveHandleHoverStroke, curveHandleHoverStroke: palette.curveHandleHoverStroke,
measurementStroke: palette.measurementStroke, measurementStroke: palette.measurementStroke,
measurementLabelBackground: theme === 'dark' ? '#0f172a' : '#ffffff', measurementLabelBackground: isDark ? '#0f172a' : '#ffffff',
measurementLabelText: theme === 'dark' ? '#e2e8f0' : '#171717', 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( const gridSteps = useMemo(
() => getVisibleGridSteps(viewBox.width, surfaceSize.width), () => getVisibleGridSteps(viewBox.width, surfaceSize.width),
[surfaceSize.width, viewBox.width], [surfaceSize.width, viewBox.width],
@@ -7620,7 +7620,7 @@ export function FloorplanPanel() {
document.body.style.userSelect = 'none' document.body.style.userSelect = 'none'
document.body.style.cursor = shouldRotate document.body.style.cursor = shouldRotate
? getGuideRotateCursor(theme === 'dark') ? getGuideRotateCursor(isDark)
: getGuideResizeCursor(corner, rotationSvg) : getGuideResizeCursor(corner, rotationSvg)
const nextDraft: GuideTransformDraft = { const nextDraft: GuideTransformDraft = {
@@ -7633,7 +7633,7 @@ export function FloorplanPanel() {
guideTransformDraftRef.current = nextDraft guideTransformDraftRef.current = nextDraft
setGuideTransformDraft(nextDraft) setGuideTransformDraft(nextDraft)
}, },
[canInteractWithGuides, guideUi, handleGuideSelect, theme], [canInteractWithGuides, guideUi, handleGuideSelect, isDark],
) )
const handleGuideTranslateStart = useCallback( const handleGuideTranslateStart = useCallback(
(guide: GuideNode, event: ReactPointerEvent<SVGRectElement>) => { (guide: GuideNode, event: ReactPointerEvent<SVGRectElement>) => {
@@ -8237,7 +8237,7 @@ export function FloorplanPanel() {
{showGuides && canInteractWithGuides && selectedGuide && ( {showGuides && canInteractWithGuides && selectedGuide && (
<FloorplanGuideHandleHint <FloorplanGuideHandleHint
anchor={guideHandleHintAnchor} anchor={guideHandleHintAnchor}
isDarkMode={theme === 'dark'} isDarkMode={isDark}
isMacPlatform={isMacPlatform} isMacPlatform={isMacPlatform}
rotationModifierPressed={rotationModifierPressed} rotationModifierPressed={rotationModifierPressed}
/> />
@@ -8605,7 +8605,7 @@ export function FloorplanPanel() {
{selectedGuide && showGuides && ( {selectedGuide && showGuides && (
<FloorplanGuideSelectionOverlay <FloorplanGuideSelectionOverlay
guide={selectedGuide} guide={selectedGuide}
isDarkMode={theme === 'dark'} isDarkMode={isDark}
onCornerHoverChange={setHoveredGuideCorner} onCornerHoverChange={setHoveredGuideCorner}
onCornerPointerDown={handleGuideCornerPointerDown} onCornerPointerDown={handleGuideCornerPointerDown}
rotationModifierPressed={rotationModifierPressed} rotationModifierPressed={rotationModifierPressed}
@@ -1,14 +1,13 @@
'use client' 'use client'
import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core' 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 { useFrame } from '@react-three/fiber'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { MathUtils, type Mesh, Vector2 } from 'three' import { MathUtils, type Mesh, Vector2 } from 'three'
import { color, float, fract, fwidth, mix, positionLocal, uniform } from 'three/tsl' import { color, float, fract, fwidth, mix, positionLocal, uniform } from 'three/tsl'
import { MeshBasicNodeMaterial } from 'three/webgpu' import { MeshBasicNodeMaterial } from 'three/webgpu'
import { useGridEvents } from '../../hooks/use-grid-events' import { useGridEvents } from '../../hooks/use-grid-events'
import { EDITOR_LAYER } from '../../lib/constants'
export const Grid = ({ export const Grid = ({
cellSize = 0.5, cellSize = 0.5,
@@ -31,11 +30,11 @@ export const Grid = ({
fadeStrength?: number fadeStrength?: number
revealRadius?: 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 // Use slightly lighter colors for dark themes' grid to make it apparent
const effectiveCellColor = theme === 'dark' ? '#555566' : cellColor const effectiveCellColor = isDark ? '#555566' : cellColor
const effectiveSectionColor = theme === 'dark' ? '#666677' : sectionColor const effectiveSectionColor = isDark ? '#666677' : sectionColor
const cursorPositionRef = useRef(new Vector2(0, 0)) const cursorPositionRef = useRef(new Vector2(0, 0))
@@ -149,7 +148,7 @@ export const Grid = ({
return ( return (
<mesh <mesh
layers={EDITOR_LAYER} layers={GRID_LAYER}
material={material} material={material}
ref={gridRef} ref={gridRef}
rotation-x={-Math.PI / 2} rotation-x={-Math.PI / 2}
@@ -88,6 +88,7 @@ const EDITOR_HOVER_STYLES: HoverStyles = {
pulse: false, pulse: false,
}, },
} }
const EDITOR_DEFAULT_RENDER = { shading: 'solid' } as const
/** /**
* Wire up module-level singletons (spatial grid, space detection, SFX) for * Wire up module-level singletons (spatial grid, space detection, SFX) for
@@ -900,7 +901,9 @@ const ViewerCanvas = memo(function ViewerCanvas({
) : null} ) : null}
<SelectionPersistenceManager enabled={hasLoadedInitialScene && !showLoader} /> <SelectionPersistenceManager enabled={hasLoadedInitialScene && !showLoader} />
<Viewer <Viewer
defaultRender={EDITOR_DEFAULT_RENDER}
hoverStyles={EDITOR_HOVER_STYLES} hoverStyles={EDITOR_HOVER_STYLES}
renderContext="editor"
selectionManager={isFirstPersonMode ? 'default' : 'custom'} selectionManager={isFirstPersonMode ? 'default' : 'custom'}
> >
<ViewerSceneContent <ViewerSceneContent
@@ -1072,7 +1075,12 @@ export default function Editor({
}, [isFirstPersonMode]) }, [isFirstPersonMode])
const previewViewerContent = ( const previewViewerContent = (
<Viewer hoverStyles={EDITOR_HOVER_STYLES} selectionManager="default"> <Viewer
defaultRender={EDITOR_DEFAULT_RENDER}
hoverStyles={EDITOR_HOVER_STYLES}
renderContext="editor"
selectionManager="default"
>
<ExportManager /> <ExportManager />
<ViewerZoneSystem /> <ViewerZoneSystem />
<CeilingSystem /> <CeilingSystem />
@@ -15,13 +15,13 @@ import {
isRegistrySelectable, isRegistrySelectable,
type NodeEvent, type NodeEvent,
nodeRegistry, nodeRegistry,
type RidgeVentNode,
type RoofEvent, type RoofEvent,
type RoofNode, type RoofNode,
type RoofSegmentEvent, type RoofSegmentEvent,
type RoofSegmentNode, type RoofSegmentNode,
resolveLevelId, resolveLevelId,
resolveMaterial, resolveMaterial,
type RidgeVentNode,
type ShelfNode, type ShelfNode,
type SlabNode, type SlabNode,
type StairEvent, type StairEvent,
@@ -30,9 +30,6 @@ import {
type StairSurfaceMaterialRole, type StairSurfaceMaterialRole,
sceneRegistry, sceneRegistry,
useScene, useScene,
type WallEvent,
type WallNode,
type WallSurfaceSide,
} from '@pascal-app/core' } from '@pascal-app/core'
import { import {
@@ -42,7 +39,6 @@ import {
getRoofMaterialArray, getRoofMaterialArray,
getStairBodyMaterials, getStairBodyMaterials,
getStairRailingMaterial, getStairRailingMaterial,
getVisibleWallMaterials,
useViewer, useViewer,
} from '@pascal-app/viewer' } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef } from 'react' import { useCallback, useEffect, useRef } from 'react'
@@ -50,11 +46,10 @@ import { type BufferGeometry, Color, type Material, type Mesh, type Object3D } f
import { import {
type ActivePaintMaterial, type ActivePaintMaterial,
buildRoofSegmentSurfaceMaterialPatch, buildRoofSegmentSurfaceMaterialPatch,
buildRoofSurfaceMaterialUpdates,
buildRoofSurfaceMaterialPatch, buildRoofSurfaceMaterialPatch,
buildRoofSurfaceMaterialUpdates,
buildSingleSurfaceMaterialPatch, buildSingleSurfaceMaterialPatch,
buildStairSurfaceMaterialPatch, buildStairSurfaceMaterialPatch,
buildWallSurfaceMaterialPatch,
hasActivePaintMaterial, hasActivePaintMaterial,
resolveActivePaintMaterialFromSelection, resolveActivePaintMaterialFromSelection,
} from '../../lib/material-paint' } from '../../lib/material-paint'
@@ -230,12 +225,14 @@ function previewCursor(cursor: string): PaintPreviewCleanup {
} }
function getSingleSurfacePreviewMaterial(material: ActivePaintMaterial): Material | null { function getSingleSurfacePreviewMaterial(material: ActivePaintMaterial): Material | null {
const shading = useViewer.getState().shading
if (material.materialPreset) { if (material.materialPreset) {
return createMaterialFromPresetRef(material.materialPreset) return createMaterialFromPresetRef(material.materialPreset, shading)
} }
if (material.material) { if (material.material) {
return createMaterial(material.material) return createMaterial(material.material, shading)
} }
return null return null
@@ -254,7 +251,13 @@ function applyRoofPaintPreview(
...node, ...node,
...buildRoofSurfaceMaterialPatch(node, role, material.material, material.materialPreset), ...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 if (!previewMaterial) return null
return previewMeshMaterial(mesh, previewMaterial) return previewMeshMaterial(mesh, previewMaterial)
@@ -275,12 +278,7 @@ function applyRoofSegmentPaintPreview(
// material lands on the matching CSG groups. // material lands on the matching CSG groups.
const previewNode: RoofSegmentNode = { const previewNode: RoofSegmentNode = {
...node, ...node,
...buildRoofSegmentSurfaceMaterialPatch( ...buildRoofSegmentSurfaceMaterialPatch(node, role, material.material, material.materialPreset),
node,
role,
material.material,
material.materialPreset,
),
} }
const resolveSlot = (r: 'top' | 'edge' | 'wall'): Material | null => { const resolveSlot = (r: 'top' | 'edge' | 'wall'): Material | null => {
const parentSpec = parent ? getEffectiveRoofSurfaceMaterial(parent, r) : undefined const parentSpec = parent ? getEffectiveRoofSurfaceMaterial(parent, r) : undefined
@@ -320,8 +318,9 @@ function applyStairPaintPreview(
...node, ...node,
...buildStairSurfaceMaterialPatch(node, role, material.material, material.materialPreset), ...buildStairSurfaceMaterialPatch(node, role, material.material, material.materialPreset),
} }
const bodyMaterials = getStairBodyMaterials(previewNode) const shading = useViewer.getState().shading
const railingMaterial = getStairRailingMaterial(previewNode) const bodyMaterials = getStairBodyMaterials(previewNode, shading)
const railingMaterial = getStairRailingMaterial(previewNode, shading)
const restores: PaintPreviewCleanup[] = [] const restores: PaintPreviewCleanup[] = []
root.traverse((object) => { root.traverse((object) => {
@@ -354,14 +353,7 @@ function applyStairPaintPreview(
} }
function applySingleSurfacePaintPreview( function applySingleSurfacePaintPreview(
node: node: FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode | BoxVentNode | RidgeVentNode,
| FenceNode
| ColumnNode
| SlabNode
| CeilingNode
| ShelfNode
| BoxVentNode
| RidgeVentNode,
material: ActivePaintMaterial, material: ActivePaintMaterial,
): PaintPreviewCleanup | null { ): PaintPreviewCleanup | null {
if (node.type === 'ceiling') { if (node.type === 'ceiling') {
@@ -881,9 +873,7 @@ export const SelectionManager = () => {
apply: apply:
compatible && role compatible && role
? () => { ? () => {
useScene useScene.getState().updateNode(
.getState()
.updateNode(
node.id as AnyNodeId, node.id as AnyNodeId,
paintCap.buildPatch({ paintCap.buildPatch({
node, node,
@@ -2,7 +2,7 @@
import type { SiteNode } from '@pascal-app/core' import type { SiteNode } from '@pascal-app/core'
import { sceneRegistry, useScene } 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 { Html } from '@react-three/drei'
import { createPortal, useFrame } from '@react-three/fiber' import { createPortal, useFrame } from '@react-three/fiber'
import { useMemo, useRef, useState } from 'react' import { useMemo, useRef, useState } from 'react'
@@ -30,11 +30,10 @@ export function SiteEdgeLabels() {
return node?.type === 'site' ? (node as SiteNode) : null return node?.type === 'site' ? (node as SiteNode) : null
}) })
const unit = useViewer((state) => state.unit) 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 siteNodeId = siteNode?.id
const isNight = theme === 'dark'
const color = isNight ? '#ffffff' : '#111111' const color = isNight ? '#ffffff' : '#111111'
const shadowColor = isNight ? '#111111' : '#ffffff' const shadowColor = isNight ? '#111111' : '#ffffff'
@@ -1,7 +1,7 @@
'use client' 'use client'
import { emitter, sceneRegistry } from '@pascal-app/core' 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 type { CameraControls } from '@react-three/drei'
import { useThree } from '@react-three/fiber' import { useThree } from '@react-three/fiber'
import { useCallback, useEffect, useRef } from 'react' import { useCallback, useEffect, useRef } from 'react'
@@ -62,6 +62,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
useEffect(() => { useEffect(() => {
const cam = new THREE.PerspectiveCamera(60, THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT, 0.1, 1000) const cam = new THREE.PerspectiveCamera(60, THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT, 0.1, 1000)
cam.layers.disable(EDITOR_LAYER) cam.layers.disable(EDITOR_LAYER)
cam.layers.disable(GRID_LAYER)
thumbnailCameraRef.current = cam thumbnailCameraRef.current = cam
let mounted = true let mounted = true
@@ -73,7 +74,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
// pass() handles MRT internally for all material types, including custom // pass() handles MRT internally for all material types, including custom
// shaders — unlike renderer.setMRT() which crashes on non-NodeMaterials. // 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) const scenePass = pass(scene, cam)
scenePass.setMRT( scenePass.setMRT(
mrt({ mrt({
@@ -18,7 +18,7 @@ import {
type WallMiterData, type WallMiterData,
type WallNode, type WallNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { getSceneTheme, useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei' import { Html } from '@react-three/drei'
import { createPortal, useFrame } from '@react-three/fiber' import { createPortal, useFrame } from '@react-three/fiber'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
@@ -515,9 +515,8 @@ function SelectedMeasurementAnnotation({ node }: { node: WallNode | ItemNode })
function WallMeasurementAnnotation({ wall }: { wall: WallNode }) { function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
const nodes = useScene((state) => state.nodes) const nodes = useScene((state) => state.nodes)
const theme = useViewer((state) => state.theme)
const unit = useViewer((state) => state.unit) 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 color = isNight ? '#ffffff' : '#111111'
const shadowColor = isNight ? '#111111' : '#ffffff' const shadowColor = isNight ? '#111111' : '#ffffff'
@@ -23,6 +23,7 @@ import {
type Object3D, type Object3D,
OrthographicCamera, OrthographicCamera,
} from 'three' } from 'three'
import { EDITOR_LAYER } from '../../lib/constants'
import { sfxEmitter } from '../../lib/sfx-bus' import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor' 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}> <group position={handle.position} rotation={[0, handle.rotationY, 0]} scale={scale}>
<mesh <mesh
frustumCulled={false} frustumCulled={false}
layers={EDITOR_LAYER}
onPointerDown={activateWallMove} onPointerDown={activateWallMove}
onPointerEnter={(event) => { onPointerEnter={(event) => {
event.stopPropagation() 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}> <group position={handle.position} rotation={[0, handle.rotationY, 0]} scale={scale}>
<mesh <mesh
frustumCulled={false} frustumCulled={false}
layers={EDITOR_LAYER}
onPointerDown={activateFenceMove} onPointerDown={activateFenceMove}
onPointerEnter={(event) => { onPointerEnter={(event) => {
event.stopPropagation() event.stopPropagation()
@@ -22,7 +22,6 @@ import { Euler, Matrix3, Quaternion, Vector3 } from 'three'
import { import {
calculateCursorRotation, calculateCursorRotation,
calculateItemRotation, calculateItemRotation,
calculateRoofRotation,
getGridAlignedDimensions, getGridAlignedDimensions,
getSideFromNormal, getSideFromNormal,
isValidWallSideFace, isValidWallSideFace,
@@ -526,14 +526,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
cursorGroupRef.current.rotation.set(0, result.cursorRotationY, 0) 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( draftNode.create(gridPosition.current, asset, initRotation, configRef.current.defaultScale)
gridPosition.current,
asset,
initRotation,
configRef.current.defaultScale,
)
const draft = draftNode.current const draft = draftNode.current
if (draft) { if (draft) {
@@ -27,6 +27,7 @@ import {
PencilLine, PencilLine,
Plus, Plus,
Redo2, Redo2,
Sparkles,
Square, Square,
SquareStack, SquareStack,
Sun, Sun,
@@ -281,19 +282,20 @@ export function EditorCommands() {
}), }),
}, },
{ {
id: 'editor.viewer.theme', id: 'editor.viewer.shading-solid',
label: () => { label: 'Switch to Solid',
const theme = useViewer.getState().theme
return theme === 'dark' ? 'Switch to Light Theme' : 'Switch to Dark Theme'
},
group: 'Viewer Controls', group: 'Viewer Controls',
icon: <Sun className="h-4 w-4" />, // icon is static; label conveys the action icon: <Box className="h-4 w-4" />,
keywords: ['theme', 'dark', 'light', 'appearance', 'color'], keywords: ['solid', 'shading', 'render', 'mode', 'performance'],
execute: () => execute: () => run(() => useViewer.getState().setShading('solid')),
run(() => { },
const { theme, setTheme } = useViewer.getState() {
setTheme(theme === 'dark' ? 'light' : 'dark') 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', id: 'editor.viewer.camera-snapshot',
@@ -184,6 +184,7 @@ export function SettingsPanel({
const resetSelection = useViewer((state) => state.resetSelection) const resetSelection = useViewer((state) => state.resetSelection)
const exportScene = useViewer((state) => state.exportScene) const exportScene = useViewer((state) => state.exportScene)
const showGrid = useViewer((state) => state.showGrid) const showGrid = useViewer((state) => state.showGrid)
const shadows = useViewer((state) => state.shadows)
const setPhase = useEditor((state) => state.setPhase) const setPhase = useEditor((state) => state.setPhase)
const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(false) const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(false)
const [pendingImport, setPendingImport] = useState<PendingImport | null>(null) const [pendingImport, setPendingImport] = useState<PendingImport | null>(null)
@@ -340,6 +341,16 @@ export function SettingsPanel({
onCheckedChange={(checked) => useViewer.getState().setShowGrid(checked)} onCheckedChange={(checked) => useViewer.getState().setShowGrid(checked)}
/> />
</div> </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> </div>
)} )}
+167 -51
View File
@@ -10,13 +10,34 @@ import {
useScene, useScene,
type ZoneNode, type ZoneNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import {
import { ArrowLeft, Camera, ChevronRight, Diamond, Layers, Moon, Sun } from 'lucide-react' CLAY_PALETTE,
import { motion } from 'motion/react' 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 Link from 'next/link'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { cn } from '../lib/utils' import { cn } from '../lib/utils'
import { ActionButton } from './ui/action-menu/action-button' import { ActionButton } from './ui/action-menu/action-button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from './ui/primitives/dropdown-menu'
import { TooltipProvider } from './ui/primitives/tooltip' import { TooltipProvider } from './ui/primitives/tooltip'
type ProjectOwner = { 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 => { const getNodeName = (node: AnyNode): string => {
if ('name' in node && node.name) return node.name if ('name' in node && node.name) return node.name
if (node.type === 'wall') return 'Wall' if (node.type === 'wall') return 'Wall'
@@ -93,7 +251,6 @@ export const ViewerOverlay = ({
const cameraMode = useViewer((s) => s.cameraMode) const cameraMode = useViewer((s) => s.cameraMode)
const levelMode = useViewer((s) => s.levelMode) const levelMode = useViewer((s) => s.levelMode)
const wallMode = useViewer((s) => s.wallMode) 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 // Subscribe only to the specific nodes we read so that creating an unrelated
// node elsewhere in the scene doesn't re-render this overlay. // 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"> <div className="dark absolute bottom-6 left-1/2 z-20 -translate-x-1/2 text-foreground">
<TooltipProvider delayDuration={0}> <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"> <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 */} {/* Scans and Guides Visibility */}
{canShowScans && ( {canShowScans && (
<ActionButton <ActionButton
@@ -392,6 +502,12 @@ export const ViewerOverlay = ({
<Camera className="h-6 w-6" /> <Camera className="h-6 w-6" />
</ActionButton> </ActionButton>
<RenderModeMenu />
<SceneThemeMenu />
<EdgesMenu />
{/* Level Mode */} {/* Level Mode */}
<ActionButton <ActionButton
className={cn( className={cn(
+6
View File
@@ -110,6 +110,12 @@ export { PanelWrapper } from './components/ui/panels/panel-wrapper'
// hardware / type / opening presets. // hardware / type / opening presets.
export { PresetsPopover } from './components/ui/panels/presets/presets-popover' export { PresetsPopover } from './components/ui/panels/presets/presets-popover'
export { PALETTE_COLORS } from './components/ui/primitives/color-dot' 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 { useSidebarStore } from './components/ui/primitives/sidebar'
export { Slider } from './components/ui/primitives/slider' export { Slider } from './components/ui/primitives/slider'
export { SceneLoader } from './components/ui/scene-loader' export { SceneLoader } from './components/ui/scene-loader'
+6 -2
View File
@@ -1,3 +1,7 @@
import { OVERLAY_LAYER } from '@pascal-app/viewer'
/** Three.js layer used for editor-only objects (helpers, grid, polygon editors). /** 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. */ * The thumbnail camera renders only layer 0, so these are excluded from thumbnails.
export const EDITOR_LAYER = 1 * 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
+10 -15
View File
@@ -8,12 +8,11 @@ import {
type ChimneyMaterialRole, type ChimneyMaterialRole,
type ChimneyNode, type ChimneyNode,
type ColumnNode, type ColumnNode,
type DormerNode,
type DormerSurfaceMaterialRole, type DormerSurfaceMaterialRole,
type FenceNode, type FenceNode,
getCatalogMaterialById, getCatalogMaterialById,
getEffectiveDormerSurfaceMaterial,
getEffectiveRoofSurfaceMaterial, getEffectiveRoofSurfaceMaterial,
getEffectiveSegmentSurfaceMaterial,
getEffectiveStairSurfaceMaterial, getEffectiveStairSurfaceMaterial,
getEffectiveWallSurfaceMaterial, getEffectiveWallSurfaceMaterial,
getLibraryMaterialIdFromRef, getLibraryMaterialIdFromRef,
@@ -21,7 +20,6 @@ import {
type MaterialTarget, type MaterialTarget,
nodeRegistry, nodeRegistry,
type RidgeVentNode, type RidgeVentNode,
getEffectiveSegmentSurfaceMaterial,
type RoofNode, type RoofNode,
type RoofSegmentNode, type RoofSegmentNode,
type RoofSegmentSurfaceMaterialRole, type RoofSegmentSurfaceMaterialRole,
@@ -143,17 +141,11 @@ export function buildRoofSegmentSurfaceMaterialPatch(
): Partial<RoofSegmentNode> { ): Partial<RoofSegmentNode> {
const nextSurfaceMaterial = { material, materialPreset } const nextSurfaceMaterial = { material, materialPreset }
const nextTop = const nextTop =
targetRole === 'top' targetRole === 'top' ? nextSurfaceMaterial : getEffectiveSegmentSurfaceMaterial(node, 'top')
? nextSurfaceMaterial
: getEffectiveSegmentSurfaceMaterial(node, 'top')
const nextEdge = const nextEdge =
targetRole === 'edge' targetRole === 'edge' ? nextSurfaceMaterial : getEffectiveSegmentSurfaceMaterial(node, 'edge')
? nextSurfaceMaterial
: getEffectiveSegmentSurfaceMaterial(node, 'edge')
const nextWall = const nextWall =
targetRole === 'wall' targetRole === 'wall' ? nextSurfaceMaterial : getEffectiveSegmentSurfaceMaterial(node, 'wall')
? nextSurfaceMaterial
: getEffectiveSegmentSurfaceMaterial(node, 'wall')
return { return {
topMaterial: nextTop.material, topMaterial: nextTop.material,
@@ -177,9 +169,12 @@ export function buildRoofSurfaceMaterialUpdates(
const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = [ const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = [
{ {
id: node.id as AnyNodeId, id: node.id as AnyNodeId,
data: buildRoofSurfaceMaterialPatch(node, targetRole, material, materialPreset) as Partial< data: buildRoofSurfaceMaterialPatch(
AnyNode node,
>, targetRole,
material,
materialPreset,
) as Partial<AnyNode>,
}, },
] ]
@@ -27,9 +27,7 @@ describe('buildBoxVentGeometry', () => {
// quads, but the bottom + top fan triangulations always include // quads, but the bottom + top fan triangulations always include
// every profile edge (including the degenerate ones — they're // every profile edge (including the degenerate ones — they're
// zero-area triangles that survive the buffer). // zero-area triangles that survive the buffer).
const box = buildBoxVentGeometry( const box = buildBoxVentGeometry(BoxVentNode.parse({ style: 'box', cornerBevel: 0 }))
BoxVentNode.parse({ style: 'box', cornerBevel: 0 }),
)
expect(box.getAttribute('position').count).toBeGreaterThan(0) expect(box.getAttribute('position').count).toBeGreaterThan(0)
// Confirm the position attribute carries finite values only. // Confirm the position attribute carries finite values only.
const positions = box.getAttribute('position').array as Float32Array const positions = box.getAttribute('position').array as Float32Array
@@ -47,9 +45,7 @@ describe('buildBoxVentGeometry', () => {
}) })
test('cap style: zero overhang drops the flange quad', () => { test('cap style: zero overhang drops the flange quad', () => {
const noFlange = buildBoxVentGeometry( const noFlange = buildBoxVentGeometry(BoxVentNode.parse({ style: 'cap', hoodOverhang: 0 }))
BoxVentNode.parse({ style: 'cap', hoodOverhang: 0 }),
)
// 10 quads × 6 vertices/quad = 60. // 10 quads × 6 vertices/quad = 60.
expect(noFlange.getAttribute('position').count).toBe(60) expect(noFlange.getAttribute('position').count).toBe(60)
}) })
+2 -1
View File
@@ -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 { boxVentParametrics } from './parametrics'
import { BoxVentNode } from './schema' import { BoxVentNode } from './schema'
@@ -33,6 +33,7 @@ export const boxVentDefinition: NodeDefinition<typeof BoxVentNode> = {
schemaVersion: 1, schemaVersion: 1,
schema: BoxVentNode, schema: BoxVentNode,
category: 'structure', category: 'structure',
surfaceRole: 'roof',
defaults: () => { defaults: () => {
const stub = BoxVentNodeSchema.parse({ id: 'bvent_default' as never, type: 'box-vent' }) const stub = BoxVentNodeSchema.parse({ id: 'bvent_default' as never, type: 'box-vent' })
+211 -73
View File
@@ -55,10 +55,7 @@ function buildBoxShape(node: BoxVentNode): THREE.BufferGeometry {
const w = node.width const w = node.width
const d = node.depth const d = node.depth
const h = node.height const h = node.height
const baseInset = Math.max( const baseInset = Math.max(0, Math.min(node.baseInset ?? 0.06, Math.min(w, d) / 2 - 0.005))
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 baseH = Math.max(0.005, Math.min(node.baseHeight ?? 0.04, h - 0.005))
const baseW = Math.max(0.01, w - 2 * baseInset) const baseW = Math.max(0.01, w - 2 * baseInset)
const baseD = Math.max(0.01, d - 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 // Lower (smaller) riser. Top is hidden under the cover but include
// it anyway — overlap is invisible and the geometry stays simple. // it anyway — overlap is invisible and the geometry stays simple.
buildRoundedExtrusion( buildRoundedExtrusion(positions, normals, uvs, baseW, baseD, 0, baseH, cornerBevel)
positions, normals, uvs,
baseW, baseD, 0, baseH, cornerBevel,
)
// Upper (larger) cover. Bottom partially shows where it overhangs the // Upper (larger) cover. Bottom partially shows where it overhangs the
// riser, so it's always rendered. // riser, so it's always rendered.
buildRoundedExtrusion( buildRoundedExtrusion(positions, normals, uvs, w, d, baseH, h, cornerBevel)
positions, normals, uvs,
w, d, baseH, h, cornerBevel,
)
return buildBufferGeometry(positions, normals, uvs) return buildBufferGeometry(positions, normals, uvs)
} }
@@ -113,10 +104,16 @@ function buildRoundedExtrusion(
if (len < 1e-9) continue // degenerate edge (zero-bevel duplicate corner points) if (len < 1e-9) continue // degenerate edge (zero-bevel duplicate corner points)
const nx = ez / len const nx = ez / len
const nz = -ex / len const nz = -ex / len
pushQuad(positions, normals, uvs, pushQuad(
[a.x, y0, a.z], [b.x, y0, b.z], positions,
[b.x, y1, b.z], [a.x, y1, a.z], normals,
[nx, 0, nz]) 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 // 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++) { for (let i = 0; i < n; i++) {
const a = profile[i]! const a = profile[i]!
const b = profile[(i + 1) % n]! const b = profile[(i + 1) % n]!
pushTri(positions, normals, uvs, pushTri(positions, normals, uvs, [0, y1, 0], [b.x, y1, b.z], [a.x, y1, a.z], [0, 1, 0])
[0, y1, 0], [b.x, y1, b.z], [a.x, y1, a.z],
[0, 1, 0])
} }
// Bottom cap (-Y normal): wind CCW from above. // Bottom cap (-Y normal): wind CCW from above.
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const a = profile[i]! const a = profile[i]!
const b = profile[(i + 1) % n]! const b = profile[(i + 1) % n]!
pushTri(positions, normals, uvs, pushTri(positions, normals, uvs, [0, y0, 0], [a.x, y0, a.z], [b.x, y0, b.z], [0, -1, 0])
[0, y0, 0], [a.x, y0, a.z], [b.x, y0, b.z],
[0, -1, 0])
} }
} }
@@ -231,27 +224,83 @@ function buildCapShape(node: BoxVentNode): THREE.BufferGeometry {
const uvs: number[] = [] const uvs: number[] = []
// ── Body (4 walls + sealed bottom) // ── Body (4 walls + sealed bottom)
pushQuad(positions, normals, uvs, pushQuad(
[hw, 0, -hd], [hw, 0, hd], [hw, bodyH, hd], [hw, bodyH, -hd], [1, 0, 0]) positions,
pushQuad(positions, normals, uvs, normals,
[-hw, 0, hd], [-hw, 0, -hd], [-hw, bodyH, -hd], [-hw, bodyH, hd], [-1, 0, 0]) uvs,
pushQuad(positions, normals, uvs, [hw, 0, -hd],
[hw, 0, hd], [-hw, 0, hd], [-hw, bodyH, hd], [hw, bodyH, hd], [0, 0, 1]) [hw, 0, hd],
pushQuad(positions, normals, uvs, [hw, bodyH, hd],
[-hw, 0, -hd], [hw, 0, -hd], [hw, bodyH, -hd], [-hw, bodyH, -hd], [0, 0, -1]) [hw, bodyH, -hd],
pushQuad(positions, normals, uvs, [1, 0, 0],
[-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],
[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) // ── Body top (only when there's a visible gap to look through)
if (capGap > 0) { if (capGap > 0) {
pushQuad(positions, normals, uvs, pushQuad(
[-hw, bodyH, hd], [-hw, bodyH, -hd], [hw, bodyH, -hd], [hw, bodyH, hd], [0, 1, 0]) 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) // ── Flange underside (the bit of the cap base that overhangs the body)
if (overhang > 0 || capGap > 0) { if (overhang > 0 || capGap > 0) {
pushQuad(positions, normals, uvs, pushQuad(
[-bw, y0, -bd], [-bw, y0, bd], [bw, y0, bd], [bw, y0, -bd], [0, -1, 0]) 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). // ── 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 dx = bw - tw // horizontal slope run on the X-facing faces
const dz = bd - td const dz = bd - td
// +X face // +X face
pushQuad(positions, normals, uvs, pushQuad(
[bw, y0, -bd], [bw, y0, bd], [tw, y1, td], [tw, y1, -td], [dx, capH, 0]) positions,
normals,
uvs,
[bw, y0, -bd],
[bw, y0, bd],
[tw, y1, td],
[tw, y1, -td],
[dx, capH, 0],
)
// -X face // -X face
pushQuad(positions, normals, uvs, pushQuad(
[-bw, y0, bd], [-bw, y0, -bd], [-tw, y1, -td], [-tw, y1, td], [-dx, capH, 0]) positions,
normals,
uvs,
[-bw, y0, bd],
[-bw, y0, -bd],
[-tw, y1, -td],
[-tw, y1, td],
[-dx, capH, 0],
)
// +Z face // +Z face
pushQuad(positions, normals, uvs, pushQuad(
[bw, y0, bd], [-bw, y0, bd], [-tw, y1, td], [tw, y1, td], [0, capH, dz]) positions,
normals,
uvs,
[bw, y0, bd],
[-bw, y0, bd],
[-tw, y1, td],
[tw, y1, td],
[0, capH, dz],
)
// -Z face // -Z face
pushQuad(positions, normals, uvs, pushQuad(
[-bw, y0, -bd], [bw, y0, -bd], [tw, y1, -td], [-tw, y1, -td], [0, capH, -dz]) 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, // ── Flat closed top plane (no hollow opening — even if topTaper is 0,
// this collapses to the original body cross-section; if topTaper is 1 // this collapses to the original body cross-section; if topTaper is 1
// it degenerates to a point and the four triangles meet, still closed). // it degenerates to a point and the four triangles meet, still closed).
pushQuad(positions, normals, uvs, pushQuad(
[-tw, y1, td], [-tw, y1, -td], [tw, y1, -td], [tw, y1, td], [0, 1, 0]) positions,
normals,
uvs,
[-tw, y1, td],
[-tw, y1, -td],
[tw, y1, -td],
[tw, y1, td],
[0, 1, 0],
)
return buildBufferGeometry(positions, normals, uvs) return buildBufferGeometry(positions, normals, uvs)
} }
@@ -301,10 +390,12 @@ function buildDomeStyleShape(node: BoxVentNode): THREE.BufferGeometry {
const bodyH = h * 0.32 const bodyH = h * 0.32
const hoodH = h - bodyH const hoodH = h - bodyH
return mergeGeometries( return (
mergeGeometries(
[buildBody(w, d, bodyH), buildDomeHood(w, d, overhang, bodyH, hoodH, 'dome')], [buildBody(w, d, bodyH), buildDomeHood(w, d, overhang, bodyH, hoodH, 'dome')],
false, false,
) ?? buildBody(w, d, bodyH) ) ?? buildBody(w, d, bodyH)
)
} }
// ─── Body ──────────────────────────────────────────────────────────── // ─── Body ────────────────────────────────────────────────────────────
@@ -317,25 +408,60 @@ function buildBody(w: number, d: number, bodyH: number): THREE.BufferGeometry {
const uvs: number[] = [] const uvs: number[] = []
// +X side // +X side
pushQuad(positions, normals, uvs, pushQuad(
[hw, 0, -hd], [hw, 0, hd], [hw, bodyH, hd], [hw, bodyH, -hd], positions,
[1, 0, 0]) normals,
uvs,
[hw, 0, -hd],
[hw, 0, hd],
[hw, bodyH, hd],
[hw, bodyH, -hd],
[1, 0, 0],
)
// -X side // -X side
pushQuad(positions, normals, uvs, pushQuad(
[-hw, 0, hd], [-hw, 0, -hd], [-hw, bodyH, -hd], [-hw, bodyH, hd], positions,
[-1, 0, 0]) normals,
uvs,
[-hw, 0, hd],
[-hw, 0, -hd],
[-hw, bodyH, -hd],
[-hw, bodyH, hd],
[-1, 0, 0],
)
// +Z side // +Z side
pushQuad(positions, normals, uvs, pushQuad(
[hw, 0, hd], [-hw, 0, hd], [-hw, bodyH, hd], [hw, bodyH, hd], positions,
[0, 0, 1]) normals,
uvs,
[hw, 0, hd],
[-hw, 0, hd],
[-hw, bodyH, hd],
[hw, bodyH, hd],
[0, 0, 1],
)
// -Z side // -Z side
pushQuad(positions, normals, uvs, pushQuad(
[-hw, 0, -hd], [hw, 0, -hd], [hw, bodyH, -hd], [-hw, bodyH, -hd], positions,
[0, 0, -1]) 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) // Bottom (closes the body so it reads as solid from below)
pushQuad(positions, normals, uvs, pushQuad(
[-hw, 0, -hd], [-hw, 0, hd], [hw, 0, hd], [hw, 0, -hd], positions,
[0, -1, 0]) normals,
uvs,
[-hw, 0, -hd],
[-hw, 0, hd],
[hw, 0, hd],
[hw, 0, -hd],
[0, -1, 0],
)
return buildBufferGeometry(positions, normals, uvs) return buildBufferGeometry(positions, normals, uvs)
} }
@@ -369,9 +495,16 @@ function buildDomeHood(
const y0 = bodyH const y0 = bodyH
// Skirt underside // Skirt underside
pushQuad(positions, normals, uvs, pushQuad(
[-bw, y0, -bd], [-bw, y0, bd], [bw, y0, bd], [bw, y0, -bd], positions,
[0, -1, 0]) 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 // Sample a low-resolution dome on a lat × lng grid. The radial decay
// is `cos(phi) ^ radialPower` — `radialPower < 1` keeps the dome wide // is `cos(phi) ^ radialPower` — `radialPower < 1` keeps the dome wide
@@ -385,10 +518,10 @@ function buildDomeHood(
for (let i = 0; i <= lat; i++) { for (let i = 0; i <= lat; i++) {
const row: THREE.Vector3[] = [] const row: THREE.Vector3[] = []
const phi = (Math.PI / 2) * (i / lat) 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) const y = y0 + hoodH * Math.sin(phi)
for (let j = 0; j <= lng; j++) { 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 x = bw * r * Math.cos(theta)
const z = bd * r * Math.sin(theta) const z = bd * r * Math.sin(theta)
row.push(new THREE.Vector3(x, y, z)) row.push(new THREE.Vector3(x, y, z))
@@ -410,9 +543,16 @@ function buildDomeHood(
// winding (see note in `pushQuad`). Swapping the cross operands here // winding (see note in `pushQuad`). Swapping the cross operands here
// keeps the dome lit from the outside, not from inside. // keeps the dome lit from the outside, not from inside.
const n = new THREE.Vector3().crossVectors(ad, ab).normalize() const n = new THREE.Vector3().crossVectors(ad, ab).normalize()
pushQuad(positions, normals, uvs, pushQuad(
[a.x, a.y, a.z], [b.x, b.y, b.z], [c.x, c.y, c.z], [d2.x, d2.y, d2.z], positions,
[n.x, n.y, n.z]) 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. * source of truth.
*/ */
export function computeBoxVentSlopeTilt( export function computeBoxVentSlopeTilt(
segment: segment: { roofType: RoofType; pitch: number; width: number; depth: number } | undefined,
| { roofType: RoofType; pitch: number; width: number; depth: number }
| undefined,
localZ: number, localZ: number,
): number { ): number {
if (!segment || segment.roofType === 'flat' || localZ === 0) return 0 if (!segment || segment.roofType === 'flat' || localZ === 0) return 0
+5 -17
View File
@@ -14,11 +14,8 @@ import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/edito
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import {
getAnalyticalNormal,
surfaceQuatFromNormal,
} from '../solar-panel/geometry'
import { resolveRoofSegmentHit } from '../roof/segment-hit' import { resolveRoofSegmentHit } from '../roof/segment-hit'
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../solar-panel/geometry'
import BoxVentPreview from './preview' 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 [previewPos, setPreviewPos] = useState<[number, number, number] | null>(null)
const [previewSurfaceQuat, setPreviewSurfaceQuat] = const [previewSurfaceQuat, setPreviewSurfaceQuat] = useState<THREE.Quaternion | null>(null)
useState<THREE.Quaternion | null>(null)
const [previewYaw, setPreviewYaw] = useState(0) const [previewYaw, setPreviewYaw] = useState(0)
useEffect(() => { useEffect(() => {
@@ -59,15 +55,9 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) {
const ventObj = sceneRegistry.nodes.get(node.id) const ventObj = sceneRegistry.nodes.get(node.id)
if (ventObj) ventObj.visible = false if (ventObj) ventObj.visible = false
const worldToBuildingLocal = ( const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => {
wx: number,
wy: number,
wz: number,
): [number, number, number] => {
const buildingId = useViewer.getState().selection.buildingId const buildingId = useViewer.getState().selection.buildingId
const buildingObj = buildingId const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
? sceneRegistry.nodes.get(buildingId as AnyNodeId)
: null
if (!buildingObj) return [wx, wy, wz] if (!buildingObj) return [wx, wy, wz]
const v = new THREE.Vector3(wx, wy, wz) const v = new THREE.Vector3(wx, wy, wz)
buildingObj.worldToLocal(v) 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. // user doesn't end up with an orphan they didn't intend to place.
const parentId = original.roofSegmentId as AnyNodeId | undefined const parentId = original.roofSegmentId as AnyNodeId | undefined
if (parentId) { if (parentId) {
const parent = useScene.getState().nodes[parentId] as const parent = useScene.getState().nodes[parentId] as RoofSegmentNode | undefined
| RoofSegmentNode
| undefined
if (parent) { if (parent) {
useScene.getState().updateNode(parentId, { useScene.getState().updateNode(parentId, {
children: (parent.children ?? []).filter((id) => id !== node.id), children: (parent.children ?? []).filter((id) => id !== node.id),
+2 -5
View File
@@ -9,7 +9,6 @@ import {
useLiveNodeOverrides, useLiveNodeOverrides,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import type { BoxVentNode } from './schema'
import { import {
ActionButton, ActionButton,
ActionGroup, ActionGroup,
@@ -23,6 +22,7 @@ import {
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Copy, Move, Trash2 } from 'lucide-react' import { Copy, Move, Trash2 } from 'lucide-react'
import { useCallback } from 'react' import { useCallback } from 'react'
import type { BoxVentNode } from './schema'
/** /**
* Inspector panel for a placed box-vent. Exposes the same parametrics * Inspector panel for a placed box-vent. Exposes the same parametrics
@@ -296,10 +296,7 @@ export default function BoxVentPanel() {
/> />
<SliderControl <SliderControl
label="Gap Height" label="Gap Height"
max={Math.max( max={Math.max(0, node.height - Math.max(0.01, node.capHeight ?? 0.07) - 0.005)}
0,
node.height - Math.max(0.01, node.capHeight ?? 0.07) - 0.005,
)}
min={0} min={0}
onChange={(v) => previewProp({ capGap: v })} onChange={(v) => previewProp({ capGap: v })}
onCommit={(v) => handleUpdate({ capGap: v })} onCommit={(v) => handleUpdate({ capGap: v })}
+5 -13
View File
@@ -16,13 +16,10 @@ import type { BoxVentNode } from './schema'
* the cursor ray and starve the placement tool of `roof:move` events. * the cursor ray and starve the placement tool of `roof:move` events.
*/ */
const BoxVentPreview = ({ node }: { node: BoxVentNode }) => { const BoxVentPreview = ({ node }: { node: BoxVentNode }) => {
const geometry = useMemo(() => buildBoxVentGeometry(node), [ const geometry = useMemo(
node.width, () => buildBoxVentGeometry(node),
node.depth, [node.width, node.depth, node.height, node.hoodOverhang, node.style],
node.height, )
node.hoodOverhang,
node.style,
])
const material = useMemo( const material = useMemo(
() => () =>
@@ -61,12 +58,7 @@ const BoxVentPreview = ({ node }: { node: BoxVentNode }) => {
}} }}
/> />
<lineSegments geometry={edgesGeometry} renderOrder={1000}> <lineSegments geometry={edgesGeometry} renderOrder={1000}>
<lineBasicMaterial <lineBasicMaterial color={0x6c_a3_ff} depthTest={false} opacity={0.95} transparent />
color={0x6c_a3_ff}
depthTest={false}
opacity={0.95}
transparent
/>
</lineSegments> </lineSegments>
</group> </group>
) )
+29 -13
View File
@@ -8,7 +8,14 @@ import {
useRegistry, useRegistry,
useScene, useScene,
} from '@pascal-app/core' } 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 { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../solar-panel/geometry' import { getAnalyticalNormal, surfaceQuatFromNormal } from '../solar-panel/geometry'
@@ -45,13 +52,17 @@ const BoxVentRenderer = ({ node: storeNode }: { node: BoxVentNode }) => {
const ref = useRef<THREE.Group>(null!) const ref = useRef<THREE.Group>(null!)
useRegistry(storeNode.id, 'box-vent', ref) useRegistry(storeNode.id, 'box-vent', ref)
const handlers = useNodeEvents(storeNode, 'box-vent') 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. // Merge live overrides (panel slider drags) on top of the store node.
// Sliders write here on every `onChange` and only flush to the scene // Sliders write here on every `onChange` and only flush to the scene
// store on `onCommit`, so the mesh updates frame-by-frame without // store on `onCommit`, so the mesh updates frame-by-frame without
// polluting undo history or triggering a full store-driven re-render. // polluting undo history or triggering a full store-driven re-render.
const overrides = useLiveNodeOverrides((s) => const overrides = useLiveNodeOverrides(
s.get(storeNode.id as AnyNodeId) as Partial<BoxVentNode> | undefined, (s) => s.get(storeNode.id as AnyNodeId) as Partial<BoxVentNode> | undefined,
) )
const node: BoxVentNode = overrides ? ({ ...storeNode, ...overrides } as BoxVentNode) : storeNode const node: BoxVentNode = overrides ? ({ ...storeNode, ...overrides } as BoxVentNode) : storeNode
@@ -65,7 +76,9 @@ const BoxVentRenderer = ({ node: storeNode }: { node: BoxVentNode }) => {
// every parametric field, including the per-style ones. Listing them // every parametric field, including the per-style ones. Listing them
// explicitly keeps the dep array tight (vs. `[node]` which would // explicitly keeps the dep array tight (vs. `[node]` which would
// also fire on `name` / `visible` flips). // also fire on `name` / `visible` flips).
const geometry = useMemo(() => buildBoxVentGeometry(node), [ const geometry = useMemo(
() => buildBoxVentGeometry(node),
[
node.style, node.style,
node.width, node.width,
node.depth, node.depth,
@@ -78,7 +91,8 @@ const BoxVentRenderer = ({ node: storeNode }: { node: BoxVentNode }) => {
node.baseInset, node.baseInset,
node.baseHeight, node.baseHeight,
node.cornerBevel, node.cornerBevel,
]) ],
)
useEffect(() => () => geometry.dispose(), [geometry]) useEffect(() => () => geometry.dispose(), [geometry])
@@ -89,11 +103,7 @@ const BoxVentRenderer = ({ node: storeNode }: { node: BoxVentNode }) => {
// ran along segment-local Z. // ran along segment-local Z.
const surfaceQuat = useMemo(() => { const surfaceQuat = useMemo(() => {
if (!segment) return new THREE.Quaternion() if (!segment) return new THREE.Quaternion()
const normal = getAnalyticalNormal( const normal = getAnalyticalNormal(node.position[0] ?? 0, node.position[2] ?? 0, segment)
node.position[0] ?? 0,
node.position[2] ?? 0,
segment,
)
return surfaceQuatFromNormal(normal, new THREE.Quaternion()) return surfaceQuatFromNormal(normal, new THREE.Quaternion())
}, [segment, node.position[0], node.position[2]]) }, [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 // DoubleSide locally so back faces of the vent body / hood don't drop
// out when the camera looks up at the eaves. // out when the camera looks up at the eaves.
const material = useMemo(() => { 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 const base = node.material
? createMaterial(node.material) ? createMaterial(node.material, shading)
: (createMaterialFromPresetRef(node.materialPreset) ?? defaultMaterial) : (createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial)
if (base.side === THREE.DoubleSide) return base if (base.side === THREE.DoubleSide) return base
const cloned = base.clone() const cloned = base.clone()
cloned.side = THREE.DoubleSide cloned.side = THREE.DoubleSide
return cloned return cloned
}, [node.material, node.materialPreset]) }, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset])
if (!segment) return null if (!segment) return null
+2 -9
View File
@@ -13,11 +13,8 @@ import { triggerSFX } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import {
getAnalyticalNormal,
surfaceQuatFromNormal,
} from '../solar-panel/geometry'
import { resolveRoofSegmentHit } from '../roof/segment-hit' import { resolveRoofSegmentHit } from '../roof/segment-hit'
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../solar-panel/geometry'
import { boxVentDefinition } from './definition' import { boxVentDefinition } from './definition'
import BoxVentPreview from './preview' import BoxVentPreview from './preview'
@@ -57,11 +54,7 @@ const BoxVentTool = () => {
useEffect(() => { useEffect(() => {
if (!activeBuildingId) return if (!activeBuildingId) return
const worldToBuildingLocal = ( const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => {
wx: number,
wy: number,
wz: number,
): [number, number, number] => {
const buildingObj = sceneRegistry.nodes.get(activeBuildingId as AnyNodeId) const buildingObj = sceneRegistry.nodes.get(activeBuildingId as AnyNodeId)
if (!buildingObj) return [wx, wy, wz] if (!buildingObj) return [wx, wy, wz]
worldPoint.set(wx, wy, wz) worldPoint.set(wx, wy, wz)
+1
View File
@@ -30,6 +30,7 @@ export const ceilingDefinition: NodeDefinition<typeof CeilingNode> = {
schemaVersion: 1, schemaVersion: 1,
schema: CeilingNode, schema: CeilingNode,
category: 'structure', category: 'structure',
surfaceRole: 'ceiling',
defaults: () => ({ defaults: () => ({
object: 'node', object: 'node',
+22 -1
View File
@@ -6,7 +6,12 @@ import {
resolveMaterial, resolveMaterial,
useRegistry, useRegistry,
} from '@pascal-app/core' } 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 { useEffect, useMemo, useRef } from 'react'
import { BufferGeometry, Float32BufferAttribute } from 'three' import { BufferGeometry, Float32BufferAttribute } from 'three'
import { float, mix, positionWorld, smoothstep } from 'three/tsl' import { float, mix, positionWorld, smoothstep } from 'three/tsl'
@@ -64,6 +69,9 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
useRegistry(node.id, 'ceiling', ref) useRegistry(node.id, 'ceiling', ref)
const handlers = useNodeEvents(node, 'ceiling') const handlers = useNodeEvents(node, 'ceiling')
const textures = useViewer((s) => s.textures)
const colorPreset = useViewer((s) => s.colorPreset)
const sceneTheme = useViewer((s) => s.sceneTheme)
useEffect( useEffect(
() => () => { () => () => {
@@ -74,11 +82,24 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
) )
const materials = useMemo(() => { 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 preset = getMaterialPresetByRef(node.materialPreset)
const props = preset?.mapProperties ?? resolveMaterial(node.material) const props = preset?.mapProperties ?? resolveMaterial(node.material)
const color = props.color || '#999999' const color = props.color || '#999999'
return getCeilingMaterials(color) return getCeilingMaterials(color)
}, [ }, [
textures,
colorPreset,
sceneTheme,
node.materialPreset, node.materialPreset,
node.material, node.material,
node.material?.preset, node.material?.preset,
@@ -27,7 +27,10 @@ const fixtureSegment = (): RoofSegmentNode =>
describe('buildChimneyGeometry', () => { describe('buildChimneyGeometry', () => {
test('returns body for default chimney with a non-empty position attribute', () => { 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(body.getAttribute('position').count).toBeGreaterThan(0)
expect(cap?.getAttribute('position').count).toBeGreaterThan(0) expect(cap?.getAttribute('position').count).toBeGreaterThan(0)
expect(flues?.getAttribute('position').count).toBeGreaterThan(0) expect(flues?.getAttribute('position').count).toBeGreaterThan(0)
@@ -43,18 +46,12 @@ describe('buildChimneyGeometry', () => {
}) })
test('cap omitted when cap=false', () => { test('cap omitted when cap=false', () => {
const { cap } = buildChimneyGeometry( const { cap } = buildChimneyGeometry(ChimneyNode.parse({ cap: false }), fixtureSegment())
ChimneyNode.parse({ cap: false }),
fixtureSegment(),
)
expect(cap).toBeNull() expect(cap).toBeNull()
}) })
test('flues omitted when flueCount=0', () => { test('flues omitted when flueCount=0', () => {
const { flues } = buildChimneyGeometry( const { flues } = buildChimneyGeometry(ChimneyNode.parse({ flueCount: 0 }), fixtureSegment())
ChimneyNode.parse({ flueCount: 0 }),
fixtureSegment(),
)
expect(flues).toBeNull() expect(flues).toBeNull()
}) })
@@ -1,10 +1,6 @@
import { describe, expect, test } from 'bun:test' import { describe, expect, test } from 'bun:test'
import { CHIMNEY_PRESET_KEYS, chimneyPresets, detectActiveChimneyPreset } from '../presets'
import { ChimneyNode } from '../schema' import { ChimneyNode } from '../schema'
import {
CHIMNEY_PRESET_KEYS,
chimneyPresets,
detectActiveChimneyPreset,
} from '../presets'
// Build a fully-formed chimney by parsing an empty object (schema fills // Build a fully-formed chimney by parsing an empty object (schema fills
// every default) and merging the preset over the top — mirrors what the // every default) and merging the preset over the top — mirrors what the
@@ -36,9 +32,7 @@ describe('detectActiveChimneyPreset', () => {
const node = applyPreset('brick') const node = applyPreset('brick')
// Brick preset sets bandStyle=double; flip it to confirm the // Brick preset sets bandStyle=double; flip it to confirm the
// detection narrows. // detection narrows.
expect( expect(detectActiveChimneyPreset({ ...node, bandStyle: 'single' as const })).toBeNull()
detectActiveChimneyPreset({ ...node, bandStyle: 'single' as const }),
).toBeNull()
}) })
test('ignores non-preset fields (dimensions, materials, placement)', () => { test('ignores non-preset fields (dimensions, materials, placement)', () => {
+2 -1
View File
@@ -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 { chimneyPaint } from './paint'
import { chimneyParametrics } from './parametrics' import { chimneyParametrics } from './parametrics'
import { ChimneyNode } from './schema' import { ChimneyNode } from './schema'
@@ -36,6 +36,7 @@ export const chimneyDefinition: NodeDefinition<typeof ChimneyNode> = {
schemaVersion: 1, schemaVersion: 1,
schema: ChimneyNode, schema: ChimneyNode,
category: 'structure', category: 'structure',
surfaceRole: 'wall',
defaults: () => { defaults: () => {
const stub = ChimneyNodeSchema.parse({ const stub = ChimneyNodeSchema.parse({
+42 -25
View File
@@ -65,10 +65,7 @@ function mergeAndDispose(parts: THREE.BufferGeometry[]): THREE.BufferGeometry {
return merged return merged
} }
export function buildChimneyGeometry( export function buildChimneyGeometry(node: ChimneyNode, segment: RoofSegmentNode): ChimneyGeometry {
node: ChimneyNode,
segment: RoofSegmentNode,
): ChimneyGeometry {
const peakY = segment.wallHeight + getActiveRoofHeight(segment) const peakY = segment.wallHeight + getActiveRoofHeight(segment)
const topY = peakY + node.heightAboveRidge const topY = peakY + node.heightAboveRidge
// Embed the body 0.2m below the eave so the bottom isn't visible // Embed the body 0.2m below the eave so the bottom isn't visible
@@ -107,11 +104,7 @@ export function buildChimneyGeometry(
// ─── Body ──────────────────────────────────────────────────────────── // ─── Body ────────────────────────────────────────────────────────────
function buildBodyGeometry( function buildBodyGeometry(node: ChimneyNode, baseY: number, topY: number): THREE.BufferGeometry {
node: ChimneyNode,
baseY: number,
topY: number,
): THREE.BufferGeometry {
const isRound = node.bodyShape === 'round' const isRound = node.bodyShape === 'round'
const w = node.width const w = node.width
const d = isRound ? node.width : node.depth const d = isRound ? node.width : node.depth
@@ -333,10 +326,7 @@ function buildFluesGeometry(node: ChimneyNode, capTopY: number): THREE.BufferGeo
// ─── Cricket ───────────────────────────────────────────────────────── // ─── Cricket ─────────────────────────────────────────────────────────
// Water-shedding wedge on the up-slope side of the chimney. // Water-shedding wedge on the up-slope side of the chimney.
function buildCricketGeometry( function buildCricketGeometry(node: ChimneyNode, baseY: number): THREE.BufferGeometry {
node: ChimneyNode,
baseY: number,
): THREE.BufferGeometry {
const w = node.width const w = node.width
const d = node.depth const d = node.depth
const cL = Math.max(0.1, node.cricketLength) const cL = Math.max(0.1, node.cricketLength)
@@ -365,19 +355,32 @@ function buildCricketGeometry(
const u0_: [number, number] = [0, 0] const u0_: [number, number] = [0, 0]
const u1_: [number, number] = [w, 0] const u1_: [number, number] = [w, 0]
const uvBottom: Record<'v0' | 'v1' | 'v2' | 'v3', [number, number]> = { 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]> = { 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]> = { 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]> = { 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]> = { 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 = ( const pushTri = (
@@ -510,9 +513,7 @@ function pushSlabFaces(
const cB = Math.max(0, Math.min(bevel, halfWB - 0.001, halfDB - 0.001)) 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)) const cT = Math.max(0, Math.min(bevel, halfWT - 0.001, halfDT - 0.001))
if (cB > 0.001 || cT > 0.001) { if (cB > 0.001 || cT > 0.001) {
pushOctagonalSlabFaces( pushOctagonalSlabFaces(positions, uvs, y0, y1, halfWB, halfDB, halfWT, halfDT, cB, cT)
positions, uvs, y0, y1, halfWB, halfDB, halfWT, halfDT, cB, cT,
)
return return
} }
@@ -541,11 +542,27 @@ function pushSlabFaces(
} }
// Bottom // Bottom
pushQuad(bBL, bTL, bTR, bBR, pushQuad(
[-halfWB, -halfDB], [-halfWB, halfDB], [halfWB, halfDB], [halfWB, -halfDB]) bBL,
bTL,
bTR,
bBR,
[-halfWB, -halfDB],
[-halfWB, halfDB],
[halfWB, halfDB],
[halfWB, -halfDB],
)
// Top // Top
pushQuad(tBL, tBR, tTR, tTL, pushQuad(
[-halfWT, -halfDT], [halfWT, -halfDT], [halfWT, halfDT], [-halfWT, halfDT]) tBL,
tBR,
tTR,
tTL,
[-halfWT, -halfDT],
[halfWT, -halfDT],
[halfWT, halfDT],
[-halfWT, halfDT],
)
// Sides // Sides
pushQuad(bBL, bBR, tBR, tBL, [-halfWB, 0], [halfWB, 0], [halfWT, t], [-halfWT, t]) 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]) pushQuad(bBR, bTR, tTR, tBR, [-halfDB, 0], [halfDB, 0], [halfDT, t], [-halfDT, t])
+1 -4
View File
@@ -313,10 +313,7 @@ function buildCutter(
return brush return brush
} }
function subtractCutters( function subtractCutters(base: THREE.BufferGeometry, cutters: Brush[]): THREE.BufferGeometry {
base: THREE.BufferGeometry,
cutters: Brush[],
): THREE.BufferGeometry {
if (cutters.length === 0) return base if (cutters.length === 0) return base
const indexed = mergeVertices(base, 1e-4) const indexed = mergeVertices(base, 1e-4)
+1 -1
View File
@@ -2,8 +2,8 @@
import { import {
type AnyNodeId, type AnyNodeId,
ChimneyNode as ChimneyNodeSchema,
type ChimneyNode, type ChimneyNode,
ChimneyNode as ChimneyNodeSchema,
emitter, emitter,
type RoofEvent, type RoofEvent,
type RoofNode, type RoofNode,
+7 -7
View File
@@ -11,10 +11,6 @@ import {
useLiveNodeOverrides, useLiveNodeOverrides,
useScene, useScene,
} from '@pascal-app/core' } 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 { import {
ActionButton, ActionButton,
ActionGroup, ActionGroup,
@@ -24,6 +20,10 @@ import {
SliderControl, SliderControl,
triggerSFX, triggerSFX,
} from '@pascal-app/editor' } 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 { import {
CHIMNEY_PRESET_KEYS, CHIMNEY_PRESET_KEYS,
CHIMNEY_PRESET_LABELS, CHIMNEY_PRESET_LABELS,
@@ -278,8 +278,9 @@ export default function ChimneyPanel() {
let newOriginWorldY = 0 let newOriginWorldY = 0
if (newSegObj) { if (newSegObj) {
newSegObj.updateWorldMatrix(true, false) newSegObj.updateWorldMatrix(true, false)
newOriginWorldY = new Vector3(target.localX, 0, target.localZ) newOriginWorldY = new Vector3(target.localX, 0, target.localZ).applyMatrix4(
.applyMatrix4(newSegObj.matrixWorld).y newSegObj.matrixWorld,
).y
} }
const newHeightAboveRidge = Math.max(0.1, oldWorldTopY - newOriginWorldY - newPeakY) const newHeightAboveRidge = Math.max(0.1, oldWorldTopY - newOriginWorldY - newPeakY)
@@ -886,7 +887,6 @@ export default function ChimneyPanel() {
)} )}
</> </>
)} )}
</PanelSection> </PanelSection>
<PanelSection title="Actions"> <PanelSection title="Actions">
+6 -9
View File
@@ -21,14 +21,10 @@ const ghostMaterial = new THREE.MeshStandardMaterial({
* segment is hit, the preview isn't shown at all (the tool guards on * segment is hit, the preview isn't shown at all (the tool guards on
* `previewPos`). * `previewPos`).
*/ */
const ChimneyPreview = ({ const ChimneyPreview = ({ node, segment }: { node: ChimneyNode; segment: RoofSegmentNode }) => {
node, const geo = useMemo(
segment, () => buildChimneyGeometry(node, segment),
}: { [
node: ChimneyNode
segment: RoofSegmentNode
}) => {
const geo = useMemo(() => buildChimneyGeometry(node, segment), [
segment.wallHeight, segment.wallHeight,
segment.pitch, segment.pitch,
segment.roofType, segment.roofType,
@@ -57,7 +53,8 @@ const ChimneyPreview = ({
node.position[0], node.position[0],
node.position[2], node.position[2],
node.rotation, node.rotation,
]) ],
)
useEffect( useEffect(
() => () => { () => () => {
+43 -11
View File
@@ -9,10 +9,13 @@ import {
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { import {
type ColorPreset,
createMaterial, createMaterial,
createMaterialFromPresetRef, createMaterialFromPresetRef,
createSurfaceRoleMaterial,
getRoofSegmentBrushes, getRoofSegmentBrushes,
useNodeEvents, useNodeEvents,
useViewer,
} from '@pascal-app/viewer' } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef } from 'react' import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
@@ -35,12 +38,16 @@ const ChimneyRenderer = ({ node: storeNode }: { node: ChimneyNode }) => {
const ref = useRef<THREE.Group>(null!) const ref = useRef<THREE.Group>(null!)
useRegistry(storeNode.id, 'chimney', ref) useRegistry(storeNode.id, 'chimney', ref)
const handlers = useNodeEvents(storeNode, 'chimney') 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 // Merge in-flight slider drags from `useLiveNodeOverrides` so the mesh
// updates while the user is still holding the slider. On release the // updates while the user is still holding the slider. On release the
// panel commits to the store and clears the override. // panel commits to the store and clears the override.
const overrides = useLiveNodeOverrides((state) => const overrides = useLiveNodeOverrides(
state.get(storeNode.id as AnyNodeId) as Partial<ChimneyNode> | undefined, (state) => state.get(storeNode.id as AnyNodeId) as Partial<ChimneyNode> | undefined,
) )
const node = useMemo<ChimneyNode>( const node = useMemo<ChimneyNode>(
() => (overrides ? { ...storeNode, ...overrides } : storeNode), () => (overrides ? { ...storeNode, ...overrides } : storeNode),
@@ -157,19 +164,44 @@ const ChimneyRenderer = ({ node: storeNode }: { node: ChimneyNode }) => {
) )
const surfaceMaterial = useMemo(() => { const surfaceMaterial = useMemo(() => {
if (node.material) return createMaterial(node.material) // Untextured chimney body (and everything in textures-off mode) takes
const preset = createMaterialFromPresetRef(node.materialPreset) // the themed 'wall' role colour; only an explicit preset/material keeps
return preset ?? fallbackBodyMaterial // its texture when textures are on.
}, [node.material, node.materialPreset, fallbackBodyMaterial]) 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(() => { const capSurfaceMaterial = useMemo(() => {
if (node.topMaterial) return createMaterial(node.topMaterial) // Cap/crown is the chimney's roof-facing surface → 'roof' role when
const preset = createMaterialFromPresetRef(node.topMaterialPreset) // 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 (preset) return preset
if (node.material) return createMaterial(node.material) if (node.material) return createMaterial(node.material, shading)
const bodyPreset = createMaterialFromPresetRef(node.materialPreset) return createMaterialFromPresetRef(node.materialPreset, shading) ?? fallbackTopMaterial
return bodyPreset ?? fallbackTopMaterial
}, [ }, [
textures,
colorPreset,
sceneTheme,
shading,
node.topMaterial, node.topMaterial,
node.topMaterialPreset, node.topMaterialPreset,
node.material, node.material,
+3 -3
View File
@@ -49,9 +49,9 @@ export function trimChimneyBodyAgainstRoof(
const indexCount = indexed.getIndex()?.count ?? 0 const indexCount = indexed.getIndex()?.count ?? 0
indexed.clearGroups() indexed.clearGroups()
if (indexCount > 0) indexed.addGroup(0, indexCount, 0) 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) const chimneyBrush = new Brush(indexed, visibleMat as unknown as THREE.MeshStandardMaterial)
chimneyBrush.updateMatrixWorld() chimneyBrush.updateMatrixWorld()
+1
View File
@@ -27,6 +27,7 @@ export const columnDefinition: NodeDefinition<typeof ColumnNode> = {
schemaVersion: 1, schemaVersion: 1,
schema: ColumnNode, schema: ColumnNode,
category: 'structure', category: 'structure',
surfaceRole: 'wall',
defaults: () => { defaults: () => {
const stub = ColumnNodeSchema.parse({ id: 'column_default' as never, type: 'column' }) const stub = ColumnNodeSchema.parse({ id: 'column_default' as never, type: 'column' })
+33 -11
View File
@@ -3,18 +3,22 @@
import { type ColumnNode, useLiveTransforms, useRegistry } from '@pascal-app/core' import { type ColumnNode, useLiveTransforms, useRegistry } from '@pascal-app/core'
import { import {
baseMaterial, baseMaterial,
type ColorPreset,
createColumnBoxGeometry, createColumnBoxGeometry,
createColumnCylinderGeometry, createColumnCylinderGeometry,
createColumnSphereGeometry, createColumnSphereGeometry,
createColumnTorusGeometry, createColumnTorusGeometry,
createMaterial, createMaterial,
createMaterialFromPresetRef, createMaterialFromPresetRef,
createSurfaceRoleMaterial,
type RenderShading,
useNodeEvents, useNodeEvents,
useViewer,
} from '@pascal-app/viewer' } from '@pascal-app/viewer'
import { createContext, useContext, useMemo, useRef } from 'react' import { createContext, useContext, useMemo, useRef } from 'react'
import { BufferGeometry, Float32BufferAttribute, type Group, type Material } from 'three' 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) const ColumnEdgeSoftnessContext = createContext(0.025)
function ColumnMaterial() { function ColumnMaterial() {
@@ -25,11 +29,20 @@ function ColumnMaterial() {
function createColumnMaterial({ function createColumnMaterial({
material, material,
materialPreset, materialPreset,
}: Pick<ColumnNode, 'material' | 'materialPreset'>) { shading,
const presetMaterial = createMaterialFromPresetRef(materialPreset) 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 (presetMaterial) return presetMaterial
if (material) return createMaterial(material) if (material) return createMaterial(material, shading)
return baseMaterial return baseMaterial(shading)
} }
function getSegments(node: ColumnNode) { function getSegments(node: ColumnNode) {
@@ -118,7 +131,7 @@ function MappedBox({
if (!geometry) return null if (!geometry) return null
return ( return (
<mesh dispose={null} position={position} rotation={rotation}> <mesh castShadow dispose={null} position={position} receiveShadow rotation={rotation}>
<primitive attach="geometry" dispose={null} object={geometry} /> <primitive attach="geometry" dispose={null} object={geometry} />
<ColumnMaterial /> <ColumnMaterial />
</mesh> </mesh>
@@ -226,7 +239,7 @@ function FlatEndedBeam({
if (!geometry) return null if (!geometry) return null
return ( return (
<mesh dispose={null}> <mesh castShadow dispose={null} receiveShadow>
<primitive attach="geometry" dispose={null} object={geometry} /> <primitive attach="geometry" dispose={null} object={geometry} />
<ColumnMaterial /> <ColumnMaterial />
</mesh> </mesh>
@@ -760,7 +773,7 @@ function MappedCylinder({
if (!geometry) return null if (!geometry) return null
return ( return (
<mesh dispose={null} position={position} rotation={rotation}> <mesh castShadow dispose={null} position={position} receiveShadow rotation={rotation}>
<primitive attach="geometry" dispose={null} object={geometry} /> <primitive attach="geometry" dispose={null} object={geometry} />
<ColumnMaterial /> <ColumnMaterial />
</mesh> </mesh>
@@ -797,7 +810,7 @@ function MappedCone({
if (!geometry) return null if (!geometry) return null
return ( return (
<mesh dispose={null} position={position} rotation={rotation}> <mesh castShadow dispose={null} position={position} receiveShadow rotation={rotation}>
<primitive attach="geometry" dispose={null} object={geometry} /> <primitive attach="geometry" dispose={null} object={geometry} />
<ColumnMaterial /> <ColumnMaterial />
</mesh> </mesh>
@@ -823,7 +836,7 @@ function MappedSphere({
if (!geometry) return null if (!geometry) return null
return ( return (
<mesh dispose={null} position={position}> <mesh castShadow dispose={null} position={position} receiveShadow>
<primitive attach="geometry" dispose={null} object={geometry} /> <primitive attach="geometry" dispose={null} object={geometry} />
<ColumnMaterial /> <ColumnMaterial />
</mesh> </mesh>
@@ -864,7 +877,7 @@ function MappedTorus({
if (!geometry) return null if (!geometry) return null
return ( return (
<mesh dispose={null} position={position} rotation={rotation}> <mesh castShadow dispose={null} position={position} receiveShadow rotation={rotation}>
<primitive attach="geometry" dispose={null} object={geometry} /> <primitive attach="geometry" dispose={null} object={geometry} />
<ColumnMaterial /> <ColumnMaterial />
</mesh> </mesh>
@@ -2062,13 +2075,22 @@ export const ColumnRenderer = ({ node }: { node: ColumnNode }) => {
const ref = useRef<Group>(null!) const ref = useRef<Group>(null!)
const handlers = useNodeEvents(node, 'column') const handlers = useNodeEvents(node, 'column')
const liveTransform = useLiveTransforms((state) => state.get(node.id)) 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( const material = useMemo(
() => () =>
createColumnMaterial({ createColumnMaterial({
material: node.material, material: node.material,
materialPreset: node.materialPreset, materialPreset: node.materialPreset,
shading,
textures,
colorPreset,
}), }),
[ [
shading,
textures,
colorPreset,
node.material, node.material,
node.material?.preset, node.material?.preset,
node.material?.properties, node.material?.properties,
+1
View File
@@ -28,6 +28,7 @@ export const doorDefinition: NodeDefinition<typeof DoorNode> = {
schemaVersion: 1, schemaVersion: 1,
schema: DoorNode, schema: DoorNode,
category: 'structure', category: 'structure',
surfaceRole: 'joinery',
// Leverage the schema's zod `.default()` annotations to compute the // Leverage the schema's zod `.default()` annotations to compute the
// full default shape — door has 40+ fields, listing them inline would // full default shape — door has 40+ fields, listing them inline would
+35 -15
View File
@@ -20,10 +20,7 @@ import {
SUBTRACTION, SUBTRACTION,
} from '@pascal-app/viewer' } from '@pascal-app/viewer'
import * as THREE from 'three' import * as THREE from 'three'
import { import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
mergeGeometries,
mergeVertices,
} from 'three/examples/jsm/utils/BufferGeometryUtils.js'
// Legacy default for the hung-wall (skirt) height. Used as a fallback // Legacy default for the hung-wall (skirt) height. Used as a fallback
// when `dormer.wallSkirtHeight` is undefined (e.g. old saved scenes). // when `dormer.wallSkirtHeight` is undefined (e.g. old saved scenes).
@@ -586,19 +583,39 @@ export function buildDormerCutShape(
const positions = new Float32Array([ const positions = new Float32Array([
// 0..3 = bottom rect (y = -skirt) — NW, NE, SE, SW // 0..3 = bottom rect (y = -skirt) — NW, NE, SE, SW
-hw, -skirt, -hd, -hw,
hw, -skirt, -hd, -skirt,
hw, -skirt, hd, -hd,
-hw, -skirt, hd, hw,
-skirt,
-hd,
hw,
-skirt,
hd,
-hw,
-skirt,
hd,
// 4..7 = eave rect (y = wallH) — NW, NE, SE, SW // 4..7 = eave rect (y = wallH) — NW, NE, SE, SW
-hw, wallH, -hd, -hw,
hw, wallH, -hd, wallH,
hw, wallH, hd, -hd,
-hw, wallH, hd, hw,
wallH,
-hd,
hw,
wallH,
hd,
-hw,
wallH,
hd,
// 8 = ridge endpoint A (- end along the ridge axis) // 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) // 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 // Triangles (CCW from outside). Windings verified by computing
@@ -643,7 +660,10 @@ export function buildDormerCutShape(
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)) geo.setAttribute('position', new THREE.BufferAttribute(positions, 3))
geo.setIndex(new THREE.BufferAttribute(new Uint16Array(indices), 1)) geo.setIndex(new THREE.BufferAttribute(new Uint16Array(indices), 1))
// CSG evaluator requires 'uv'; cut brushes are never rendered so zeros are fine. // 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() geo.computeVertexNormals()
return geo return geo
} }
+2 -1
View File
@@ -1,8 +1,8 @@
import { import {
type AnyNode, type AnyNode,
DormerNode as DormerNodeSchema,
type DormerNode as DormerNodeType, type DormerNode as DormerNodeType,
type NodeDefinition, type NodeDefinition,
DormerNode as DormerNodeSchema,
} from '@pascal-app/core' } from '@pascal-app/core'
import { buildDormerRoofCut } from './csg-geometry' import { buildDormerRoofCut } from './csg-geometry'
import { dormerPaint } from './paint' import { dormerPaint } from './paint'
@@ -30,6 +30,7 @@ export const dormerDefinition: NodeDefinition<typeof DormerNode> = {
schemaVersion: 1, schemaVersion: 1,
schema: DormerNode, schema: DormerNode,
category: 'structure', category: 'structure',
surfaceRole: 'roof',
defaults: () => { defaults: () => {
// Zod fills in id/type via their .default() factories; we strip // Zod fills in id/type via their .default() factories; we strip
+1 -1
View File
@@ -4,8 +4,8 @@ export {
dormerSupportsArch, dormerSupportsArch,
dormerSupportsCornerRadii, dormerSupportsCornerRadii,
} from './geometry' } from './geometry'
export { DormerNode, getEffectiveDormerSurfaceMaterial } from './schema'
export type { export type {
DormerSurfaceMaterialRole, DormerSurfaceMaterialRole,
DormerSurfaceMaterialSpec, DormerSurfaceMaterialSpec,
} from './schema' } from './schema'
export { DormerNode, getEffectiveDormerSurfaceMaterial } from './schema'
+6 -4
View File
@@ -86,7 +86,11 @@ const MoveDormerTool = ({ node }: { node: DormerNode }) => {
node.metadata && typeof node.metadata === 'object' && !Array.isArray(node.metadata) node.metadata && typeof node.metadata === 'object' && !Array.isArray(node.metadata)
? (node.metadata as Record<string, unknown>) ? (node.metadata as Record<string, unknown>)
: {} : {}
const { isNew: _isNew, isTransient: _isTransient, ...rest } = m as { const {
isNew: _isNew,
isTransient: _isTransient,
...rest
} = m as {
isNew?: boolean isNew?: boolean
isTransient?: boolean isTransient?: boolean
} }
@@ -125,9 +129,7 @@ const MoveDormerTool = ({ node }: { node: DormerNode }) => {
children: (prevSeg.children ?? []).filter((id) => id !== node.id), children: (prevSeg.children ?? []).filter((id) => id !== node.id),
}) })
} }
const newSeg = state.nodes[hit.segment.id as AnyNodeId] as const newSeg = state.nodes[hit.segment.id as AnyNodeId] as RoofSegmentNode | undefined
| RoofSegmentNode
| undefined
if (newSeg && !(newSeg.children ?? []).includes(node.id)) { if (newSeg && !(newSeg.children ?? []).includes(node.id)) {
state.updateNode(hit.segment.id as AnyNodeId, { state.updateNode(hit.segment.id as AnyNodeId, {
children: [...(newSeg.children ?? []), node.id], children: [...(newSeg.children ?? []), node.id],
@@ -1,12 +1,7 @@
'use client' 'use client'
import type { DormerNode } from '@pascal-app/core' import type { DormerNode } from '@pascal-app/core'
import { import { PanelSection, SegmentedControl, SliderControl, ToggleControl } from '@pascal-app/editor'
PanelSection,
SegmentedControl,
SliderControl,
ToggleControl,
} from '@pascal-app/editor'
import { useState } from 'react' import { useState } from 'react'
type WindowShape = DormerNode['windowShape'] type WindowShape = DormerNode['windowShape']
@@ -134,9 +129,12 @@ export function DormerWindowSection({
windowShape: v as WindowShape, windowShape: v as WindowShape,
...(v === 'rounded' ...(v === 'rounded'
? { ? {
windowCornerRadii: windowCornerRadii.map((r) => windowCornerRadii: windowCornerRadii.map((r) => Math.min(r, maxRadius)) as [
Math.min(r, maxRadius), number,
) as [number, number, number, number], number,
number,
number,
],
} }
: {}), : {}),
}) })
@@ -257,12 +255,8 @@ export function DormerWindowSection({
label="Columns" label="Columns"
max={8} max={8}
min={1} min={1}
onChange={(v) => onChange={(v) => previewProp({ windowColumns: Math.max(1, Math.min(8, Math.round(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))) })}
}
onCommit={(v) =>
commitProp({ windowColumns: Math.max(1, Math.min(8, Math.round(v))) })
}
precision={0} precision={0}
restoreOnCommit={false} restoreOnCommit={false}
step={1} step={1}
+1 -2
View File
@@ -55,8 +55,7 @@ export default function DormerPanel() {
const overrides = useLiveNodeOverrides((s) => const overrides = useLiveNodeOverrides((s) =>
selectedId ? (s.get(selectedId as AnyNodeId) as Partial<DormerNode> | undefined) : undefined, selectedId ? (s.get(selectedId as AnyNodeId) as Partial<DormerNode> | undefined) : undefined,
) )
const node = const node = storeNode && overrides ? ({ ...storeNode, ...overrides } as DormerNode) : storeNode
storeNode && overrides ? ({ ...storeNode, ...overrides } as DormerNode) : storeNode
const handleUpdate = useCallback( const handleUpdate = useCallback(
(updates: Partial<DormerNode>) => { (updates: Partial<DormerNode>) => {
+17 -5
View File
@@ -30,9 +30,7 @@ export const dormerParametrics: ParametricDescriptor<DormerNode> = {
}, },
{ {
label: 'Hung wall', label: 'Hung wall',
fields: [ fields: [{ key: 'wallSkirtHeight', kind: 'number', unit: 'm', min: 0.2, max: 6, step: 0.05 }],
{ key: 'wallSkirtHeight', kind: 'number', unit: 'm', min: 0.2, max: 6, step: 0.05 },
],
}, },
{ {
label: 'Window opening', label: 'Window opening',
@@ -53,9 +51,23 @@ export const dormerParametrics: ParametricDescriptor<DormerNode> = {
{ {
label: 'Window frame', label: 'Window frame',
fields: [ 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: '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', key: 'windowShape',
kind: 'enum', kind: 'enum',
+53 -66
View File
@@ -10,56 +10,22 @@ import {
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { import {
type ColorPreset,
createMaterial, createMaterial,
createMaterialFromPresetRef, createMaterialFromPresetRef,
createSurfaceRoleMaterial,
useNodeEvents, useNodeEvents,
useViewer,
} from '@pascal-app/viewer' } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef } from 'react'
import type * as THREE from 'three'
import { import {
buildDormerFallbackGeometry, buildDormerFallbackGeometry,
DORMER_GABLE_MATERIAL_INDEX, DORMER_GABLE_MATERIAL_INDEX,
generateDormerGeometry, generateDormerGeometry,
} from './csg-geometry' } from './csg-geometry'
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import DormerWindowAssembly from './window-assembly' 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 DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => {
const ref = useRef<THREE.Group>(null!) const ref = useRef<THREE.Group>(null!)
useRegistry(storeNode.id, 'dormer', ref) 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 liveOverrides = useLiveNodeOverrides((state) => state.get(storeNode.id as AnyNodeId))
const isLiveDrag = !!liveOverrides && Object.keys(liveOverrides).length > 0 const isLiveDrag = !!liveOverrides && Object.keys(liveOverrides).length > 0
const node = useMemo( const node = useMemo(
() => () => (liveOverrides ? ({ ...storeNode, ...liveOverrides } as DormerNode) : storeNode),
liveOverrides ? ({ ...storeNode, ...liveOverrides } as DormerNode) : storeNode,
[storeNode, liveOverrides], [storeNode, liveOverrides],
) )
@@ -85,28 +50,43 @@ const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => {
: undefined, : 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 top = getEffectiveDormerSurfaceMaterial(node, 'top')
const side = getEffectiveDormerSurfaceMaterial(node, 'side') const side = getEffectiveDormerSurfaceMaterial(node, 'side')
const wall = getEffectiveDormerSurfaceMaterial(node, 'wall') const wall = getEffectiveDormerSurfaceMaterial(node, 'wall')
const resolve = (spec: { material?: DormerNode['material']; materialPreset?: string }) => { const resolve = (
if (spec.materialPreset) return createMaterialFromPresetRef(spec.materialPreset) spec: { material?: DormerNode['material']; materialPreset?: string },
if (spec.material) return createMaterial(spec.material) role: () => THREE.Material,
return null ) => {
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 w = resolve(wall, wallRole)
const sideMat = resolve(side) const s = resolve(side, roofRole)
const wallMat = resolve(wall) const t = resolve(top, roofRole)
if (!(topMat || sideMat || wallMat)) return null
const w = wallMat ?? defaultWallMat
const s = sideMat ?? defaultSideMat
const t = topMat ?? defaultRoofMat
return [w, s, w, t, w] as THREE.Material[] return [w, s, w, t, w] as THREE.Material[]
}, [ }, [
textures,
colorPreset,
sceneTheme,
shading,
node.material, node.material,
node.materialPreset, node.materialPreset,
node.topMaterial, node.topMaterial,
@@ -117,16 +97,25 @@ const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => {
node.wallMaterialPreset, node.wallMaterialPreset,
]) ])
const material = resolvedMaterials ?? defaultDormerMaterials // The window frame bars / sill take the 'joinery' role when untextured;
const frameSideMat = resolvedMaterials ? resolvedMaterials[1]! : defaultSideMat // 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( // 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 (!segment) return null
if (isLiveDrag) return buildDormerFallbackGeometry(node) if (isLiveDrag) return buildDormerFallbackGeometry(node)
return generateDormerGeometry(node, segment) return generateDormerGeometry(node, segment)
}, }, [
[
isLiveDrag, isLiveDrag,
segment, segment,
node.id, node.id,
@@ -150,8 +139,7 @@ const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => {
node.windowCornerRadii[1], node.windowCornerRadii[1],
node.windowCornerRadii[2], node.windowCornerRadii[2],
node.windowCornerRadii[3], node.windowCornerRadii[3],
], ])
)
useEffect(() => () => geometry?.dispose(), [geometry]) useEffect(() => () => geometry?.dispose(), [geometry])
@@ -171,9 +159,7 @@ const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => {
rotation-y={segment.rotation ?? 0} rotation-y={segment.rotation ?? 0}
visible={node.visible} visible={node.visible}
> >
<group <group position={[node.position[0] ?? 0, node.position[1] ?? 0, node.position[2] ?? 0]}>
position={[node.position[0] ?? 0, node.position[1] ?? 0, node.position[2] ?? 0]}
>
<group rotation-y={node.rotation ?? 0} {...handlers}> <group rotation-y={node.rotation ?? 0} {...handlers}>
<mesh <mesh
castShadow castShadow
@@ -184,6 +170,7 @@ const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => {
/> />
<DormerWindowAssembly <DormerWindowAssembly
frameMaterial={frameSideMat} frameMaterial={frameSideMat}
glassMaterial={glassMat}
node={node} node={node}
segment={segment} segment={segment}
/> />
+1 -1
View File
@@ -1,5 +1,5 @@
export { DormerNode, getEffectiveDormerSurfaceMaterial } from '@pascal-app/core'
export type { export type {
DormerSurfaceMaterialRole, DormerSurfaceMaterialRole,
DormerSurfaceMaterialSpec, DormerSurfaceMaterialSpec,
} from '@pascal-app/core' } 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 { useEffect, useRef, useState } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { resolveRoofSegmentHit } from '../roof/segment-hit' import { resolveRoofSegmentHit } from '../roof/segment-hit'
import { import { DORMER_PLACEMENT_ROTATION_STEP, DORMER_PLACEMENT_SNAP_M } from './geometry'
DORMER_PLACEMENT_ROTATION_STEP,
DORMER_PLACEMENT_SNAP_M,
} from './geometry'
const tmpMatrix = new THREE.Matrix4() const tmpMatrix = new THREE.Matrix4()
const tmpInv = new THREE.Matrix4() const tmpInv = new THREE.Matrix4()
@@ -138,9 +135,7 @@ export function useDormerPlacement(opts: {
const target = e.target as HTMLElement | null const target = e.target as HTMLElement | null
if ( if (
target && target &&
(target.tagName === 'INPUT' || (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
target.tagName === 'TEXTAREA' ||
target.isContentEditable)
) )
return return
const dir = e.shiftKey ? -1 : 1 const dir = e.shiftKey ? -1 : 1
@@ -1,10 +1,9 @@
'use client' 'use client'
import type { DormerNode, RoofSegmentNode } from '@pascal-app/core' 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 { useEffect, useMemo } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { getDormerExposedFaces, getDormerSkirtWindowDims } from './csg-geometry'
import { buildDormerWindowGeometries, type DormerWindowShape } from './window-frame' import { buildDormerWindowGeometries, type DormerWindowShape } from './window-frame'
/** /**
@@ -22,10 +21,12 @@ const DormerWindowAssembly = ({
node, node,
segment, segment,
frameMaterial, frameMaterial,
glassMaterial,
}: { }: {
node: DormerNode node: DormerNode
segment: RoofSegmentNode segment: RoofSegmentNode
frameMaterial: THREE.Material frameMaterial: THREE.Material
glassMaterial: THREE.Material
}) => { }) => {
const skirtWin = useMemo( const skirtWin = useMemo(
() => getDormerSkirtWindowDims(node), () => getDormerSkirtWindowDims(node),
+10 -2
View File
@@ -54,7 +54,11 @@ export function buildDormerWindowGeometries(
const innerHole = const innerHole =
shape === 'arch' 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) : createDormerRoundedShape(winW - 2 * safeFt, winH - 2 * safeFt, insetRadii)
outerShape.holes.push(innerHole) outerShape.holes.push(innerHole)
@@ -88,7 +92,11 @@ export function buildDormerWindowGeometries(
const glassShape = const glassShape =
shape === 'arch' 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) : createDormerRoundedShape(winW - 2 * safeFt, winH - 2 * safeFt, insetRadii)
const glassGeo = new THREE.ExtrudeGeometry(glassShape, { const glassGeo = new THREE.ExtrudeGeometry(glassShape, {
depth: 0.008, depth: 0.008,
@@ -15,6 +15,7 @@ export const elevatorDefinition: NodeDefinition<typeof ElevatorNode> = {
schemaVersion: 1, schemaVersion: 1,
schema: ElevatorNode, schema: ElevatorNode,
category: 'structure', category: 'structure',
surfaceRole: 'joinery',
defaults: () => { defaults: () => {
const stub = ElevatorNodeSchema.parse({ id: 'elevator_default' as never, type: 'elevator' }) const stub = ElevatorNodeSchema.parse({ id: 'elevator_default' as never, type: 'elevator' })
+297 -126
View File
@@ -23,7 +23,14 @@ import {
useRegistry, useRegistry,
useScene, useScene,
} from '@pascal-app/core' } 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 { useFrame } from '@react-three/fiber'
import { useCallback, useLayoutEffect, useMemo, useRef } from 'react' import { useCallback, useLayoutEffect, useMemo, useRef } from 'react'
import { import {
@@ -31,7 +38,7 @@ import {
CylinderGeometry, CylinderGeometry,
type Group, type Group,
type InstancedMesh, type InstancedMesh,
MeshStandardMaterial, type Material,
Object3D, Object3D,
TorusGeometry, TorusGeometry,
} from 'three' } from 'three'
@@ -58,184 +65,343 @@ const SHAFT_TOP_FRAME_CLEARANCE = 0.006
type ElevatorDoorPanelStyleValue = ElevatorNode['doorPanelStyle'] type ElevatorDoorPanelStyleValue = ElevatorNode['doorPanelStyle']
type ElevatorDoorStyleValue = ElevatorNode['doorStyle'] type ElevatorDoorStyleValue = ElevatorNode['doorStyle']
const SHAFT_WALL_MATERIAL = new MeshStandardMaterial({ type ElevatorMaterial = Material & {
color: SHAFT_WALL_COLOR, depthWrite?: boolean
metalness: 0.08, emissive?: { set: (color: string) => void }
roughness: 0.56, emissiveIntensity?: number
}) metalness?: number
const SHAFT_SIDE_MATERIAL = new MeshStandardMaterial({ opacity?: number
color: SHAFT_SIDE_COLOR, roughness?: number
metalness: 0.12, transparent?: boolean
roughness: 0.58, }
})
const SHAFT_TRIM_MATERIAL = new MeshStandardMaterial({ type ElevatorMaterialParams = {
color: SHAFT_TRIM_COLOR, color: string
metalness: 0.2, depthWrite?: boolean
roughness: 0.38, emissive?: string
}) emissiveIntensity?: number
const CAB_MATERIAL = new MeshStandardMaterial({ metalness?: number
color: CAB_COLOR, opacity?: number
metalness: 0.2, roughness?: number
roughness: 0.48, transparent?: boolean
}) }
const DOOR_MATERIAL = new MeshStandardMaterial({
color: DOOR_COLOR, function createElevatorMaterial(params: ElevatorMaterialParams, shading: RenderShading): Material {
metalness: 0.34, const material = createDefaultMaterial(
roughness: 0.34, params.color,
}) params.roughness ?? 0.5,
const DOOR_GROOVE_MATERIAL = new MeshStandardMaterial({ shading,
color: '#5f6978', ) as ElevatorMaterial
metalness: 0.28,
roughness: 0.42, if ('metalness' in material) material.metalness = params.metalness ?? 0
}) if ('roughness' in material && params.roughness !== undefined) {
const GLASS_MATERIAL = new MeshStandardMaterial({ 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
}
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, color: GLASS_COLOR,
depthWrite: false, depthWrite: false,
metalness: 0, metalness: 0,
opacity: 0.2, opacity: 0.2,
roughness: 0.08, roughness: 0.08,
transparent: true, transparent: true,
}) },
const PANEL_MATERIAL = new MeshStandardMaterial({ shading,
color: PANEL_COLOR, ),
metalness: 0.32, PANEL_MATERIAL: createElevatorMaterial(
roughness: 0.36, { color: PANEL_COLOR, metalness: 0.32, roughness: 0.36 },
}) shading,
const LANDING_PANEL_MATERIAL = new MeshStandardMaterial({ ),
color: PANEL_COLOR, LANDING_PANEL_MATERIAL: createElevatorMaterial(
metalness: 0.25, { color: PANEL_COLOR, metalness: 0.25, roughness: 0.4 },
roughness: 0.4, shading,
}) ),
const INDICATOR_SCREEN_MATERIALS = { INDICATOR_SCREEN_MATERIALS: {
active: new MeshStandardMaterial({ active: createElevatorMaterial(
{
color: '#041f2f', color: '#041f2f',
emissive: '#0ea5e9', emissive: '#0ea5e9',
emissiveIntensity: 0.16, emissiveIntensity: 0.16,
metalness: 0.12, metalness: 0.12,
roughness: 0.38, roughness: 0.38,
}), },
idle: new MeshStandardMaterial({ shading,
color: '#111827', ),
metalness: 0.12, idle: createElevatorMaterial({ color: '#111827', metalness: 0.12, roughness: 0.38 }, shading),
roughness: 0.38, },
}), INDICATOR_GLYPH_MATERIALS: {
} active: createElevatorMaterial(
const INDICATOR_GLYPH_MATERIALS = { {
active: new MeshStandardMaterial({
color: '#38bdf8', color: '#38bdf8',
emissive: '#38bdf8', emissive: '#38bdf8',
emissiveIntensity: 0.36, emissiveIntensity: 0.36,
metalness: 0.08, metalness: 0.08,
roughness: 0.32, roughness: 0.32,
}), },
idle: new MeshStandardMaterial({ shading,
),
idle: createElevatorMaterial(
{
color: '#94a3b8', color: '#94a3b8',
emissive: '#94a3b8', emissive: '#94a3b8',
emissiveIntensity: 0.18, emissiveIntensity: 0.18,
metalness: 0.08, metalness: 0.08,
roughness: 0.32, roughness: 0.32,
}), },
} shading,
const BUTTON_FACE_MATERIALS = { ),
active: new MeshStandardMaterial({ },
BUTTON_FACE_MATERIALS: {
active: createElevatorMaterial(
{
color: '#38bdf8', color: '#38bdf8',
emissive: '#38bdf8', emissive: '#38bdf8',
emissiveIntensity: 0.28, emissiveIntensity: 0.28,
metalness: 0.22, metalness: 0.22,
roughness: 0.3, roughness: 0.3,
}), },
queued: new MeshStandardMaterial({ shading,
),
queued: createElevatorMaterial(
{
color: '#fbbf24', color: '#fbbf24',
emissive: '#fbbf24', emissive: '#fbbf24',
emissiveIntensity: 0.18, emissiveIntensity: 0.18,
metalness: 0.22, metalness: 0.22,
roughness: 0.3, roughness: 0.3,
}), },
idle: new MeshStandardMaterial({ shading,
color: '#d6dde7', ),
metalness: 0.22, idle: createElevatorMaterial({ color: '#d6dde7', metalness: 0.22, roughness: 0.3 }, shading),
roughness: 0.3, disabled: createElevatorMaterial(
}), { color: '#475569', metalness: 0.12, roughness: 0.52 },
disabled: new MeshStandardMaterial({ shading,
color: '#475569', ),
metalness: 0.12, },
roughness: 0.52, BUTTON_RING_MATERIALS: {
}), active: createElevatorMaterial(
} {
const BUTTON_RING_MATERIALS = {
active: new MeshStandardMaterial({
color: '#0ea5e9', color: '#0ea5e9',
emissive: '#0ea5e9', emissive: '#0ea5e9',
emissiveIntensity: 0.16, emissiveIntensity: 0.16,
metalness: 0.48, metalness: 0.48,
roughness: 0.28, roughness: 0.28,
}), },
queued: new MeshStandardMaterial({ shading,
),
queued: createElevatorMaterial(
{
color: '#f59e0b', color: '#f59e0b',
emissive: '#f59e0b', emissive: '#f59e0b',
emissiveIntensity: 0.1, emissiveIntensity: 0.1,
metalness: 0.48, metalness: 0.48,
roughness: 0.28, roughness: 0.28,
}), },
idle: new MeshStandardMaterial({ shading,
color: '#64748b', ),
metalness: 0.48, idle: createElevatorMaterial({ color: '#64748b', metalness: 0.48, roughness: 0.28 }, shading),
roughness: 0.28, disabled: createElevatorMaterial(
}), { color: '#334155', metalness: 0.28, roughness: 0.5 },
disabled: new MeshStandardMaterial({ shading,
color: '#334155', ),
metalness: 0.28, },
roughness: 0.5, BUTTON_GLOW_MATERIALS: {
}), active: createElevatorMaterial(
} {
const BUTTON_GLOW_MATERIALS = {
active: new MeshStandardMaterial({
color: '#38bdf8', color: '#38bdf8',
depthWrite: false, depthWrite: false,
emissive: '#38bdf8', emissive: '#38bdf8',
emissiveIntensity: 0.28, emissiveIntensity: 0.28,
opacity: 0.58, opacity: 0.58,
transparent: true, transparent: true,
}), },
queued: new MeshStandardMaterial({ shading,
),
queued: createElevatorMaterial(
{
color: '#fbbf24', color: '#fbbf24',
depthWrite: false, depthWrite: false,
emissive: '#fbbf24', emissive: '#fbbf24',
emissiveIntensity: 0.18, emissiveIntensity: 0.18,
opacity: 0.58, opacity: 0.58,
transparent: true, transparent: true,
}), },
} shading,
const BUTTON_LABEL_MATERIALS = { ),
lit: new MeshStandardMaterial({ },
color: '#111827', BUTTON_LABEL_MATERIALS: {
metalness: 0.12, lit: createElevatorMaterial({ color: '#111827', metalness: 0.12, roughness: 0.34 }, shading),
roughness: 0.34, idle: createElevatorMaterial({ color: '#334155', metalness: 0.12, roughness: 0.34 }, shading),
}), disabled: createElevatorMaterial(
idle: new MeshStandardMaterial({ { color: '#94a3b8', metalness: 0.08, roughness: 0.5 },
color: '#334155', shading,
metalness: 0.12, ),
roughness: 0.34, },
}), QUEUE_STRIP_MATERIALS: {
disabled: new MeshStandardMaterial({ queued: createElevatorMaterial(
color: '#94a3b8', {
metalness: 0.08,
roughness: 0.5,
}),
}
const QUEUE_STRIP_MATERIALS = {
queued: new MeshStandardMaterial({
color: '#fbbf24', color: '#fbbf24',
emissive: '#fbbf24', emissive: '#fbbf24',
emissiveIntensity: 0.16, emissiveIntensity: 0.16,
metalness: 0.18, metalness: 0.18,
roughness: 0.42, roughness: 0.42,
}), },
idle: new MeshStandardMaterial({ shading,
color: '#64748b', ),
metalness: 0.18, idle: createElevatorMaterial({ color: '#64748b', metalness: 0.18, roughness: 0.42 }, shading),
roughness: 0.42, },
}), }
}
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
}
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' type ElevatorButtonAction = 'open-door' | 'request-level'
@@ -285,7 +451,7 @@ function BoxPrimitive({
scale, scale,
}: { }: {
castShadow?: boolean castShadow?: boolean
material: MeshStandardMaterial material: Material
position?: Vector3Tuple position?: Vector3Tuple
receiveShadow?: boolean receiveShadow?: boolean
rotation?: Vector3Tuple rotation?: Vector3Tuple
@@ -314,7 +480,7 @@ function MeshButtonLabel({
}: { }: {
faceSign?: -1 | 1 faceSign?: -1 | 1
label: string label: string
material: MeshStandardMaterial material: Material
position: [number, number, number] position: [number, number, number]
scale: number scale: number
}) { }) {
@@ -381,7 +547,7 @@ function ElevatorDirectionGlyph({
scale, scale,
}: { }: {
direction: 'down' | 'up' | null direction: 'down' | 'up' | null
material: MeshStandardMaterial material: Material
position: [number, number, number] position: [number, number, number]
scale: number scale: number
}) { }) {
@@ -484,7 +650,7 @@ function DoorOpenGlyph({
positionZ, positionZ,
scale, scale,
}: { }: {
material: MeshStandardMaterial material: Material
positionZ: number positionZ: number
scale: number scale: number
}) { }) {
@@ -950,6 +1116,9 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => {
const ref = useRef<Group>(null!) const ref = useRef<Group>(null!)
const cabRef = useRef<Group>(null) const cabRef = useRef<Group>(null)
const handlers = useNodeEvents(node, 'elevator') 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 liveOverrides = useLiveNodeOverrides((state) => state.get(node.id))
const liveTransform = useLiveTransforms((state) => state.get(node.id)) const liveTransform = useLiveTransforms((state) => state.get(node.id))
const renderNode = useMemo( const renderNode = useMemo(
@@ -960,6 +1129,8 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => {
useShallow((state) => getElevatorLevelContextNodes(renderNode, state.nodes)), useShallow((state) => getElevatorLevelContextNodes(renderNode, state.nodes)),
) )
setElevatorMaterials(shading, textures, colorPreset)
useRegistry(node.id, 'elevator', ref) useRegistry(node.id, 'elevator', ref)
const { entries, defaultEntry, shaftBaseY, totalHeight } = useMemo( const { entries, defaultEntry, shaftBaseY, totalHeight } = useMemo(
+1
View File
@@ -23,6 +23,7 @@ export const fenceDefinition: NodeDefinition<typeof FenceNode> = {
schemaVersion: 1, schemaVersion: 1,
schema: FenceNode, schema: FenceNode,
category: 'structure', category: 'structure',
surfaceRole: 'wall',
defaults: () => ({ defaults: () => ({
object: 'node', object: 'node',
+11 -3
View File
@@ -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 { Group, Mesh } from 'three'
import type { FenceNode } from './schema' import type { FenceNode } from './schema'
@@ -18,10 +22,14 @@ import type { FenceNode } from './schema'
* once the legacy system file is deleted. Until then `generateFenceGeometry` * once the legacy system file is deleted. Until then `generateFenceGeometry`
* is publicly re-exported from viewer. * 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 group = new Group()
const geometry = generateFenceGeometry(node) 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.castShadow = true
mesh.receiveShadow = true mesh.receiveShadow = true
group.add(mesh) group.add(mesh)
+4 -4
View File
@@ -26,7 +26,7 @@ import {
snapFenceDraftPoint, snapFenceDraftPoint,
triggerSFX, triggerSFX,
} from '@pascal-app/editor' } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { getSceneTheme, useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei' import { Html } from '@react-three/drei'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { BoxGeometry, BufferGeometry, DoubleSide, type Group, type Mesh, Vector3 } from 'three' 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 = () => { export const FenceTool: React.FC = () => {
const unit = useViewer((state) => state.unit) 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 cursorRef = useRef<Group>(null)
const previewRef = useRef<Mesh>(null!) const previewRef = useRef<Mesh>(null!)
const startingPoint = useRef(new Vector3(0, 0, 0)) const startingPoint = useRef(new Vector3(0, 0, 0))
@@ -421,8 +421,8 @@ export const FenceTool: React.FC = () => {
const buildingState = useRef(0) const buildingState = useRef(0)
const shiftPressed = useRef(false) const shiftPressed = useRef(false)
const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null) const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)
const measurementColor = theme === 'dark' ? '#ffffff' : '#111111' const measurementColor = isDark ? '#ffffff' : '#111111'
const measurementShadowColor = theme === 'dark' ? '#111111' : '#ffffff' const measurementShadowColor = isDark ? '#111111' : '#ffffff'
useEffect(() => { useEffect(() => {
let previousFenceEnd: FencePlanPoint | null = null let previousFenceEnd: FencePlanPoint | null = null
+10 -10
View File
@@ -1,25 +1,25 @@
import type { AnyNodeDefinition, Plugin } from '@pascal-app/core' import type { AnyNodeDefinition, Plugin } from '@pascal-app/core'
import { boxVentDefinition } from './box-vent' import { boxVentDefinition } from './box-vent'
import { buildingDefinition } from './building' 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 { ceilingDefinition } from './ceiling'
import { chimneyDefinition } from './chimney'
import { columnDefinition } from './column' import { columnDefinition } from './column'
import { doorDefinition } from './door' import { doorDefinition } from './door'
import { dormerDefinition } from './dormer'
import { elevatorDefinition } from './elevator' import { elevatorDefinition } from './elevator'
import { fenceDefinition } from './fence' import { fenceDefinition } from './fence'
import { guideDefinition } from './guide' import { guideDefinition } from './guide'
import { itemDefinition } from './item' import { itemDefinition } from './item'
import { levelDefinition } from './level' import { levelDefinition } from './level'
import { ridgeVentDefinition } from './ridge-vent'
import { roofDefinition } from './roof' import { roofDefinition } from './roof'
import { roofSegmentDefinition } from './roof-segment' import { roofSegmentDefinition } from './roof-segment'
import { scanDefinition } from './scan' import { scanDefinition } from './scan'
import { shelfDefinition } from './shelf' import { shelfDefinition } from './shelf'
import { siteDefinition } from './site' import { siteDefinition } from './site'
import { skylightDefinition } from './skylight'
import { slabDefinition } from './slab' import { slabDefinition } from './slab'
import { solarPanelDefinition } from './solar-panel'
import { spawnDefinition } from './spawn' import { spawnDefinition } from './spawn'
import { stairDefinition } from './stair' import { stairDefinition } from './stair'
import { stairSegmentDefinition } from './stair-segment' import { stairSegmentDefinition } from './stair-segment'
@@ -83,25 +83,25 @@ export const builtinPlugin: Plugin = {
export { boxVentDefinition } from './box-vent' export { boxVentDefinition } from './box-vent'
export { buildingDefinition } from './building' 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 { ceilingDefinition } from './ceiling'
export { chimneyDefinition } from './chimney'
export { columnDefinition } from './column' export { columnDefinition } from './column'
export { doorDefinition } from './door' export { doorDefinition } from './door'
export { dormerDefinition } from './dormer'
export { elevatorDefinition } from './elevator' export { elevatorDefinition } from './elevator'
export { fenceDefinition } from './fence' export { fenceDefinition } from './fence'
export { guideDefinition } from './guide' export { guideDefinition } from './guide'
export { itemDefinition } from './item' export { itemDefinition } from './item'
export { levelDefinition } from './level' export { levelDefinition } from './level'
export { ridgeVentDefinition } from './ridge-vent'
export { roofDefinition } from './roof' export { roofDefinition } from './roof'
export { roofSegmentDefinition } from './roof-segment' export { roofSegmentDefinition } from './roof-segment'
export { scanDefinition } from './scan' export { scanDefinition } from './scan'
export { shelfDefinition } from './shelf' export { shelfDefinition } from './shelf'
export { siteDefinition } from './site' export { siteDefinition } from './site'
export { skylightDefinition } from './skylight'
export { slabDefinition } from './slab' export { slabDefinition } from './slab'
export { solarPanelDefinition } from './solar-panel'
export { spawnDefinition } from './spawn' export { spawnDefinition } from './spawn'
export { stairDefinition } from './stair' export { stairDefinition } from './stair'
export { stairSegmentDefinition } from './stair-segment' export { stairSegmentDefinition } from './stair-segment'
+1
View File
@@ -41,6 +41,7 @@ export const itemDefinition: NodeDefinition<typeof ItemNode> = {
schemaVersion: 1, schemaVersion: 1,
schema: ItemNode, schema: ItemNode,
category: 'furnish', category: 'furnish',
surfaceRole: 'furnishing',
// Defaults shape is cast: the schema requires a fully-typed `asset` // Defaults shape is cast: the schema requires a fully-typed `asset`
// field, but in practice items are always created from the catalog // field, but in practice items are always created from the catalog
+55 -17
View File
@@ -12,12 +12,17 @@ import {
} from '@pascal-app/core' } from '@pascal-app/core'
import { import {
baseMaterial, baseMaterial,
type ColorPreset,
createDefaultMaterial,
createSurfaceRoleMaterial,
ErrorBoundary, ErrorBoundary,
glassMaterial, glassMaterial,
NodeRenderer, NodeRenderer,
type RenderShading,
resolveCdnUrl, resolveCdnUrl,
useItemLightPool, useItemLightPool,
useNodeEvents, useNodeEvents,
useViewer,
} from '@pascal-app/viewer' } from '@pascal-app/viewer'
import { useAnimations } from '@react-three/drei' import { useAnimations } from '@react-three/drei'
import { Clone } from '@react-three/drei/core/Clone' 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 type { AnimationAction, Group, Material, Mesh } from 'three'
import { MathUtils } from 'three' import { MathUtils } from 'three'
import { positionLocal, smoothstep, time } from 'three/tsl' 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') { if (original.name.toLowerCase() === 'glass') {
return glassMaterial return glassMaterial
} }
return baseMaterial if (!textures) return createSurfaceRoleMaterial('furnishing', colorPreset)
return baseMaterial(shading)
} }
const BrokenItemFallback = ({ node }: { node: ItemNode }) => { const BrokenItemFallback = ({ node }: { node: ItemNode }) => {
const handlers = useNodeEvents(node, 'item') const handlers = useNodeEvents(node, 'item')
const shading = useViewer((s) => s.shading)
const [w, h, d] = node.asset.dimensions 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 ( return (
<mesh position-y={h / 2} {...handlers}> <mesh position-y={h / 2} {...handlers}>
<boxGeometry args={[w, h, d]} /> <boxGeometry args={[w, h, d]} />
<meshStandardMaterial color="#ef4444" opacity={0.6} transparent wireframe /> <primitive attach="material" object={material} />
</mesh> </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 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 function getPreviewMaterial(shading: RenderShading): Material {
previewMaterial.transparent = true 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 PreviewModel = ({ node }: { node: ItemNode }) => {
const shading = useViewer((s) => s.shading)
return ( return (
<mesh material={previewMaterial} position-y={node.asset.dimensions[1] / 2}> <mesh material={getPreviewMaterial(shading)} position-y={node.asset.dimensions[1] / 2}>
<boxGeometry <boxGeometry
args={[node.asset.dimensions[0], node.asset.dimensions[1], node.asset.dimensions[2]]} 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 { scene, nodes, animations } = useGLTF(resolveCdnUrl(node.asset.src) || '')
const ref = useRef<Group>(null!) const ref = useRef<Group>(null!)
const { actions } = useAnimations(animations, ref) 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 // Freeze the interactive definition at mount — asset schemas don't change at runtime
const interactiveRef = useRef(node.asset.interactive) const interactiveRef = useRef(node.asset.interactive)
@@ -131,7 +167,9 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
// Handle both single material and material array cases // Handle both single material and material array cases
if (Array.isArray(mesh.material)) { 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') hasGlass = mesh.material.some((mat) => mat.name === 'glass')
// Fix geometry groups that reference materialIndex beyond the material // Fix geometry groups that reference materialIndex beyond the material
@@ -146,14 +184,14 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
} }
} }
} else { } else {
mesh.material = getMaterialForOriginal(mesh.material) mesh.material = getMaterialForOriginal(mesh.material, shading, textures, colorPreset)
hasGlass = mesh.material.name === 'glass' hasGlass = mesh.material.name === 'glass'
} }
mesh.castShadow = !hasGlass mesh.castShadow = !hasGlass
mesh.receiveShadow = !hasGlass mesh.receiveShadow = !hasGlass
} }
}) })
}, [scene]) }, [scene, shading, textures, colorPreset])
const interactive = interactiveRef.current const interactive = interactiveRef.current
const animEffect = const animEffect =
@@ -12,21 +12,26 @@ describe('buildRidgeVentGeometry', () => {
}) })
test('each style produces a different vertex count (no accidental fallthrough)', () => { test('each style produces a different vertex count (no accidental fallthrough)', () => {
const standard = buildRidgeVentGeometry(RidgeVentNode.parse({ style: 'standard' })) const standard = buildRidgeVentGeometry(
.getAttribute('position').count RidgeVentNode.parse({ style: 'standard' }),
const shingled = buildRidgeVentGeometry(RidgeVentNode.parse({ style: 'shingled' })) ).getAttribute('position').count
.getAttribute('position').count const shingled = buildRidgeVentGeometry(
const metal = buildRidgeVentGeometry(RidgeVentNode.parse({ style: 'metal' })) RidgeVentNode.parse({ style: 'shingled' }),
.getAttribute('position').count ).getAttribute('position').count
const metal = buildRidgeVentGeometry(RidgeVentNode.parse({ style: 'metal' })).getAttribute(
'position',
).count
expect(new Set([standard, shingled, metal]).size).toBe(3) expect(new Set([standard, shingled, metal]).size).toBe(3)
}) })
test('endCaps adds vertices on every style', () => { test('endCaps adds vertices on every style', () => {
for (const style of ['standard', 'shingled', 'metal'] as const) { for (const style of ['standard', 'shingled', 'metal'] as const) {
const without = buildRidgeVentGeometry(RidgeVentNode.parse({ style, endCaps: false })) const without = buildRidgeVentGeometry(
.getAttribute('position').count RidgeVentNode.parse({ style, endCaps: false }),
const withCaps = buildRidgeVentGeometry(RidgeVentNode.parse({ style, endCaps: true })) ).getAttribute('position').count
.getAttribute('position').count const withCaps = buildRidgeVentGeometry(
RidgeVentNode.parse({ style, endCaps: true }),
).getAttribute('position').count
expect(withCaps).toBeGreaterThan(without) expect(withCaps).toBeGreaterThan(without)
} }
}) })
@@ -19,6 +19,7 @@ export const ridgeVentDefinition: NodeDefinition<typeof RidgeVentNode> = {
schemaVersion: 1, schemaVersion: 1,
schema: RidgeVentNode, schema: RidgeVentNode,
category: 'structure', category: 'structure',
surfaceRole: 'roof',
defaults: () => { defaults: () => {
const stub = RidgeVentNodeSchema.parse({ const stub = RidgeVentNodeSchema.parse({
+145 -79
View File
@@ -49,11 +49,7 @@ export function buildRidgeVentGeometry(node: RidgeVentNode): THREE.BufferGeometr
// ─── Standard curved cap ───────────────────────────────────────────── // ─── Standard curved cap ─────────────────────────────────────────────
function buildCurvedCapProfile( function buildCurvedCapProfile(halfLen: number, halfW: number, h: number): THREE.BufferGeometry {
halfLen: number,
halfW: number,
h: number,
): THREE.BufferGeometry {
const positions: number[] = [] const positions: number[] = []
const normals: number[] = [] const normals: number[] = []
const uvs: number[] = [] const uvs: number[] = []
@@ -81,15 +77,27 @@ function buildCurvedCapProfile(
const fnz = -dy / fLen const fnz = -dy / fLen
const fny = dz / fLen const fny = dz / fLen
pushQuad(positions, normals, uvs, pushQuad(
[-halfLen, oy0, oz0], [halfLen, oy0, oz0], positions,
[halfLen, oy1, oz1], [-halfLen, oy1, oz1], normals,
[0, fny, fnz]) uvs,
[-halfLen, oy0, oz0],
[halfLen, oy0, oz0],
[halfLen, oy1, oz1],
[-halfLen, oy1, oz1],
[0, fny, fnz],
)
pushQuad(positions, normals, uvs, pushQuad(
[-halfLen, iy1, iz1], [halfLen, iy1, iz1], positions,
[halfLen, iy0, iz0], [-halfLen, iy0, iz0], normals,
[0, -fny, -fnz]) uvs,
[-halfLen, iy1, iz1],
[halfLen, iy1, iz1],
[halfLen, iy0, iz0],
[-halfLen, iy0, iz0],
[0, -fny, -fnz],
)
} }
// Eave bottoms // Eave bottoms
@@ -97,15 +105,27 @@ function buildCurvedCapProfile(
const [oz, oy] = outerPts[idx]! const [oz, oy] = outerPts[idx]!
const [iz, iy] = innerPts[idx]! const [iz, iy] = innerPts[idx]!
if (idx === 0) { if (idx === 0) {
pushQuad(positions, normals, uvs, pushQuad(
[-halfLen, iy, iz], [halfLen, iy, iz], positions,
[halfLen, oy, oz], [-halfLen, oy, oz], normals,
[0, -1, 0]) uvs,
[-halfLen, iy, iz],
[halfLen, iy, iz],
[halfLen, oy, oz],
[-halfLen, oy, oz],
[0, -1, 0],
)
} else { } else {
pushQuad(positions, normals, uvs, pushQuad(
[-halfLen, oy, oz], [halfLen, oy, oz], positions,
[halfLen, iy, iz], [-halfLen, iy, iz], normals,
[0, -1, 0]) 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 return pts
} }
function buildShingledProfile( function buildShingledProfile(halfLen: number, halfW: number, h: number): THREE.BufferGeometry {
halfLen: number,
halfW: number,
h: number,
): THREE.BufferGeometry {
const positions: number[] = [] const positions: number[] = []
const normals: number[] = [] const normals: number[] = []
const uvs: number[] = [] const uvs: number[] = []
@@ -185,34 +201,58 @@ function buildShingledProfile(
const fnz = -dy / fLen const fnz = -dy / fLen
const fny = dz / fLen const fny = dz / fLen
pushQuad(positions, normals, uvs, pushQuad(
[-halfLen, oy0, oz0], [halfLen, oy0, oz0], positions,
[halfLen, oy1, oz1], [-halfLen, oy1, oz1], normals,
[0, fny, fnz]) uvs,
[-halfLen, oy0, oz0],
[halfLen, oy0, oz0],
[halfLen, oy1, oz1],
[-halfLen, oy1, oz1],
[0, fny, fnz],
)
pushQuad(positions, normals, uvs, pushQuad(
[-halfLen, iy1, iz1], [halfLen, iy1, iz1], positions,
[halfLen, iy0, iz0], [-halfLen, iy0, iz0], normals,
[0, -fny, -fnz]) uvs,
[-halfLen, iy1, iz1],
[halfLen, iy1, iz1],
[halfLen, iy0, iz0],
[-halfLen, iy0, iz0],
[0, -fny, -fnz],
)
} }
// Eave bottoms // Eave bottoms
{ {
const [oz, oy] = outerPts[0]! const [oz, oy] = outerPts[0]!
const [iz, iy] = innerPts[0]! const [iz, iy] = innerPts[0]!
pushQuad(positions, normals, uvs, pushQuad(
[-halfLen, iy, iz], [halfLen, iy, iz], positions,
[halfLen, oy, oz], [-halfLen, oy, oz], normals,
[0, -1, 0]) uvs,
[-halfLen, iy, iz],
[halfLen, iy, iz],
[halfLen, oy, oz],
[-halfLen, oy, oz],
[0, -1, 0],
)
} }
{ {
const last = outerPts.length - 1 const last = outerPts.length - 1
const [oz, oy] = outerPts[last]! const [oz, oy] = outerPts[last]!
const [iz, iy] = innerPts[last]! const [iz, iy] = innerPts[last]!
pushQuad(positions, normals, uvs, pushQuad(
[-halfLen, oy, oz], [halfLen, oy, oz], positions,
[halfLen, iy, iz], [-halfLen, iy, iz], normals,
[0, -1, 0]) uvs,
[-halfLen, oy, oz],
[halfLen, oy, oz],
[halfLen, iy, iz],
[-halfLen, iy, iz],
[0, -1, 0],
)
} }
// Tab divider ridges along the length // Tab divider ridges along the length
@@ -236,13 +276,26 @@ function buildShingledProfile(
const r0z = oz0 + fnz * ridgeH const r0z = oz0 + fnz * ridgeH
const r1y = oy1 + fny * ridgeH const r1y = oy1 + fny * ridgeH
const r1z = oz1 + fnz * ridgeH const r1z = oz1 + fnz * ridgeH
pushQuad(positions, normals, uvs, pushQuad(
[x, r0y, r0z], [x, r1y, r1z], [x, oy1, oz1], [x, oy0, oz0], positions,
[1, 0, 0]) normals,
pushQuad(positions, normals, uvs, uvs,
[x, r0y, r0z], [x, r1y, r1z], [x, r0y, r0z],
[x - ridgeD, oy1, oz1], [x - ridgeD, oy0, oz0], [x, r1y, r1z],
[0, fny, fnz]) [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] return [0, dz / len, -dy / len]
} }
function buildMetalProfile( function buildMetalProfile(halfLen: number, halfW: number, h: number): THREE.BufferGeometry {
halfLen: number,
halfW: number,
h: number,
): THREE.BufferGeometry {
const positions: number[] = [] const positions: number[] = []
const normals: number[] = [] const normals: number[] = []
const uvs: number[] = [] const uvs: number[] = []
@@ -361,36 +410,56 @@ function buildMetalProfile(
const outerN = segNormal(oz0, oy0, oz1, oy1) const outerN = segNormal(oz0, oy0, oz1, oy1)
const innerN = segNormal(iz0, iy0, iz1, iy1).map((v) => -v) const innerN = segNormal(iz0, iy0, iz1, iy1).map((v) => -v)
pushQuad(positions, normals, uvs, pushQuad(
[-halfLen, oy0, oz0], [halfLen, oy0, oz0], positions,
[halfLen, oy1, oz1], [-halfLen, oy1, oz1], normals,
outerN) uvs,
[-halfLen, oy0, oz0],
[halfLen, oy0, oz0],
[halfLen, oy1, oz1],
[-halfLen, oy1, oz1],
outerN,
)
pushQuad(positions, normals, uvs, pushQuad(
[-halfLen, iy1, iz1], [halfLen, iy1, iz1], positions,
[halfLen, iy0, iz0], [-halfLen, iy0, iz0], normals,
innerN) uvs,
[-halfLen, iy1, iz1],
[halfLen, iy1, iz1],
[halfLen, iy0, iz0],
[-halfLen, iy0, iz0],
innerN,
)
} }
// Eave bottoms // Eave bottoms
pushQuad(positions, normals, uvs, pushQuad(
[-halfLen, inner[0]![1], inner[0]![0]], [halfLen, inner[0]![1], inner[0]![0]], positions,
[halfLen, outer[0]![1], outer[0]![0]], [-halfLen, outer[0]![1], outer[0]![0]], normals,
[0, -1, 0]) 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 const last = outer.length - 1
pushQuad(positions, normals, uvs, pushQuad(
[-halfLen, outer[last]![1], outer[last]![0]], [halfLen, outer[last]![1], outer[last]![0]], positions,
[halfLen, inner[last]![1], inner[last]![0]], [-halfLen, inner[last]![1], inner[last]![0]], normals,
[0, -1, 0]) 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) return buildBufferGeometry(positions, normals, uvs)
} }
function buildMetalEndCaps( function buildMetalEndCaps(halfLen: number, halfW: number, h: number): THREE.BufferGeometry | null {
halfLen: number,
halfW: number,
h: number,
): THREE.BufferGeometry | null {
const positions: number[] = [] const positions: number[] = []
const normals: number[] = [] const normals: number[] = []
const uvs: number[] = [] const uvs: number[] = []
@@ -415,10 +484,7 @@ function buildMetalEndCaps(
// ─── Helpers ───────────────────────────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────────
function offsetProfileInward( function offsetProfileInward(pts: [number, number][], t: number): [number, number][] {
pts: [number, number][],
t: number,
): [number, number][] {
const result: [number, number][] = [] const result: [number, number][] = []
for (let i = 0; i < pts.length; i++) { for (let i = 0; i < pts.length; i++) {
const [z, y] = pts[i]! const [z, y] = pts[i]!
+3 -11
View File
@@ -51,15 +51,9 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
const ventObj = sceneRegistry.nodes.get(node.id) const ventObj = sceneRegistry.nodes.get(node.id)
if (ventObj) ventObj.visible = false if (ventObj) ventObj.visible = false
const worldToBuildingLocal = ( const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => {
wx: number,
wy: number,
wz: number,
): [number, number, number] => {
const buildingId = useViewer.getState().selection.buildingId const buildingId = useViewer.getState().selection.buildingId
const buildingObj = buildingId const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
? sceneRegistry.nodes.get(buildingId as AnyNodeId)
: null
if (!buildingObj) return [wx, wy, wz] if (!buildingObj) return [wx, wy, wz]
const v = new THREE.Vector3(wx, wy, wz) const v = new THREE.Vector3(wx, wy, wz)
buildingObj.worldToLocal(v) buildingObj.worldToLocal(v)
@@ -142,9 +136,7 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
if (isNew) { if (isNew) {
const parentId = original.roofSegmentId as AnyNodeId | undefined const parentId = original.roofSegmentId as AnyNodeId | undefined
if (parentId) { if (parentId) {
const parent = useScene.getState().nodes[parentId] as const parent = useScene.getState().nodes[parentId] as RoofSegmentNode | undefined
| RoofSegmentNode
| undefined
if (parent) { if (parent) {
useScene.getState().updateNode(parentId, { useScene.getState().updateNode(parentId, {
children: (parent.children ?? []).filter((id) => id !== node.id), children: (parent.children ?? []).filter((id) => id !== node.id),
+5 -13
View File
@@ -6,13 +6,10 @@ import { buildRidgeVentGeometry } from './geometry'
import type { RidgeVentNode } from './schema' import type { RidgeVentNode } from './schema'
const RidgeVentPreview = ({ node }: { node: RidgeVentNode }) => { const RidgeVentPreview = ({ node }: { node: RidgeVentNode }) => {
const geometry = useMemo(() => buildRidgeVentGeometry(node), [ const geometry = useMemo(
node.length, () => buildRidgeVentGeometry(node),
node.width, [node.length, node.width, node.height, node.style, node.endCaps],
node.height, )
node.style,
node.endCaps,
])
const material = useMemo( const material = useMemo(
() => () =>
@@ -51,12 +48,7 @@ const RidgeVentPreview = ({ node }: { node: RidgeVentNode }) => {
}} }}
/> />
<lineSegments geometry={edgesGeometry} renderOrder={1000}> <lineSegments geometry={edgesGeometry} renderOrder={1000}>
<lineBasicMaterial <lineBasicMaterial color={0x6c_a3_ff} depthTest={false} opacity={0.9} transparent />
color={0x6c_a3_ff}
depthTest={false}
opacity={0.9}
transparent
/>
</lineSegments> </lineSegments>
</group> </group>
) )
+25 -11
View File
@@ -7,7 +7,14 @@ import {
useRegistry, useRegistry,
useScene, useScene,
} from '@pascal-app/core' } 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 { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { buildRidgeVentGeometry } from './geometry' import { buildRidgeVentGeometry } from './geometry'
@@ -42,6 +49,10 @@ const RidgeVentRenderer = ({ node }: { node: RidgeVentNode }) => {
const ref = useRef<THREE.Group>(null!) const ref = useRef<THREE.Group>(null!)
useRegistry(node.id, 'ridge-vent', ref) useRegistry(node.id, 'ridge-vent', ref)
const handlers = useNodeEvents(node, 'ridge-vent') 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) => const segment = useScene((state) =>
node.roofSegmentId node.roofSegmentId
@@ -49,13 +60,10 @@ const RidgeVentRenderer = ({ node }: { node: RidgeVentNode }) => {
: undefined, : undefined,
) )
const geometry = useMemo(() => buildRidgeVentGeometry(node), [ const geometry = useMemo(
node.length, () => buildRidgeVentGeometry(node),
node.width, [node.length, node.width, node.height, node.style, node.endCaps],
node.height, )
node.style,
node.endCaps,
])
useEffect(() => () => geometry.dispose(), [geometry]) useEffect(() => () => geometry.dispose(), [geometry])
@@ -65,14 +73,20 @@ const RidgeVentRenderer = ({ node }: { node: RidgeVentNode }) => {
// — so clone the resolved material and force `DoubleSide` locally // — so clone the resolved material and force `DoubleSide` locally
// without mutating the shared cache entry. // without mutating the shared cache entry.
const material = useMemo(() => { 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 const base = node.material
? createMaterial(node.material) ? createMaterial(node.material, shading)
: (createMaterialFromPresetRef(node.materialPreset) ?? defaultMaterial) : (createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial)
if (base.side === THREE.DoubleSide) return base if (base.side === THREE.DoubleSide) return base
const cloned = base.clone() const cloned = base.clone()
cloned.side = THREE.DoubleSide cloned.side = THREE.DoubleSide
return cloned return cloned
}, [node.material, node.materialPreset]) }, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset])
if (!segment) return null if (!segment) return null
+1 -5
View File
@@ -48,11 +48,7 @@ const RidgeVentTool = () => {
useEffect(() => { useEffect(() => {
if (!activeBuildingId) return if (!activeBuildingId) return
const worldToBuildingLocal = ( const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => {
wx: number,
wy: number,
wz: number,
): [number, number, number] => {
const buildingObj = sceneRegistry.nodes.get(activeBuildingId as AnyNodeId) const buildingObj = sceneRegistry.nodes.get(activeBuildingId as AnyNodeId)
if (!buildingObj) return [wx, wy, wz] if (!buildingObj) return [wx, wy, wz]
worldPoint.set(wx, wy, wz) worldPoint.set(wx, wy, wz)
@@ -14,6 +14,7 @@ export const roofSegmentDefinition: NodeDefinition<typeof RoofSegmentNode> = {
schemaVersion: 1, schemaVersion: 1,
schema: RoofSegmentNode, schema: RoofSegmentNode,
category: 'structure', category: 'structure',
surfaceRole: 'roof',
defaults: () => { defaults: () => {
const stub = RoofSegmentNodeSchema.parse({ const stub = RoofSegmentNodeSchema.parse({
+28 -13
View File
@@ -19,7 +19,7 @@ import {
} from '@pascal-app/viewer' } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef } from 'react' import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three' 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 }) => { export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
const ref = useRef<THREE.Mesh>(null!) const ref = useRef<THREE.Mesh>(null!)
@@ -29,6 +29,10 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
const handlers = useNodeEvents(node, 'roof-segment') const handlers = useNodeEvents(node, 'roof-segment')
const debugColors = useViewer((s) => s.debugColors) const debugColors = useViewer((s) => s.debugColors)
const 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 const parentNode = node.parentId
? (nodes[node.parentId as AnyNodeId] as RoofNode | undefined) ? (nodes[node.parentId as AnyNodeId] as RoofNode | undefined)
: undefined : undefined
@@ -59,32 +63,37 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
const parentSpec = parentNode ? getEffectiveRoofSurfaceMaterial(parentNode, role) : undefined const parentSpec = parentNode ? getEffectiveRoofSurfaceMaterial(parentNode, role) : undefined
const spec = getEffectiveSegmentSurfaceMaterial(node, role, parentSpec) const spec = getEffectiveSegmentSurfaceMaterial(node, role, parentSpec)
if (typeof spec.materialPreset === 'string') { if (typeof spec.materialPreset === 'string') {
const resolved = createMaterialFromPresetRef(spec.materialPreset) const resolved = createMaterialFromPresetRef(spec.materialPreset, shading)
if (resolved) return resolved if (resolved) return resolved
} }
if (spec.material !== undefined) { if (spec.material !== undefined) {
return createMaterial(spec.material) return createMaterial(spec.material, shading)
} }
return null 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 edge = resolveSlot('edge')
const wall = resolveSlot('wall') const wall = resolveSlot('wall')
const top = resolveSlot('top') const top = resolveSlot('top')
if (!(edge || wall || top)) { if (!(edge || wall || top)) {
// Nothing set anywhere — fall back to the parent roof's array (which return themedArray
// applies its own per-role resolution + defaults) or to null so the
// renderer picks the package-level `roofMaterials` defaults.
return parentNode ? getRoofMaterialArray(parentNode) : null
} }
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 [ return [
edge ?? wall ?? top ?? fallback(), edge ?? wall ?? top ?? slot(0),
wall ?? edge ?? top ?? fallback(), wall ?? edge ?? top ?? slot(1),
wall ?? edge ?? top ?? fallback(), wall ?? edge ?? top ?? slot(2),
top ?? wall ?? edge ?? fallback(), top ?? wall ?? edge ?? slot(3),
] as THREE.Material[] ] as THREE.Material[]
}, [ }, [
node.material, node.material,
@@ -96,9 +105,15 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
node.wallMaterial, node.wallMaterial,
node.wallMaterialPreset, node.wallMaterialPreset,
parentNode, parentNode,
shading,
textures,
colorPreset,
sceneTheme,
]) ])
const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials const material = debugColors
? getRoofDebugMaterials(shading)
: customMaterial || getRoofMaterials(shading, textures, colorPreset)
useEffect(() => { useEffect(() => {
return () => { return () => {
+1
View File
@@ -16,6 +16,7 @@ export const roofDefinition: NodeDefinition<typeof RoofNode> = {
schemaVersion: 1, schemaVersion: 1,
schema: RoofNode, schema: RoofNode,
category: 'structure', category: 'structure',
surfaceRole: 'roof',
defaults: () => { defaults: () => {
const stub = RoofNodeSchema.parse({ id: 'roof_default' as never, type: 'roof' }) const stub = RoofNodeSchema.parse({ id: 'roof_default' as never, type: 'roof' })
+1 -1
View File
@@ -1,2 +1,2 @@
export { roofDefinition } from './definition' export { roofDefinition } from './definition'
export { resolveRoofSegmentHit, type RoofSegmentHit } from './segment-hit' export { type RoofSegmentHit, resolveRoofSegmentHit } from './segment-hit'
+1 -7
View File
@@ -207,13 +207,7 @@ export default function RoofPanel() {
// Same code path as the top palette — see `tool-manager.tsx:28`'s // Same code path as the top palette — see `tool-manager.tsx:28`'s
// `nodeRegistry.get(tool)?.tool` dispatch. // `nodeRegistry.get(tool)?.tool` dispatch.
const activateTool = useCallback( const activateTool = useCallback(
(kind: (kind: 'box-vent' | 'ridge-vent' | 'chimney' | 'solar-panel' | 'skylight' | 'dormer') => {
| 'box-vent'
| 'ridge-vent'
| 'chimney'
| 'solar-panel'
| 'skylight'
| 'dormer') => {
triggerSFX('sfx:item-pick') triggerSFX('sfx:item-pick')
useEditor.getState().setTool(kind) useEditor.getState().setTool(kind)
if (useEditor.getState().mode !== 'build') { if (useEditor.getState().mode !== 'build') {
+12 -3
View File
@@ -12,7 +12,7 @@ import { getRoofMaterialArray, NodeRenderer, useNodeEvents, useViewer } from '@p
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react' import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { roofDebugMaterials, roofMaterials } from './roof-materials' import { getRoofDebugMaterials, getRoofMaterials } from './roof-materials'
export const RoofRenderer = ({ node }: { node: RoofNode }) => { export const RoofRenderer = ({ node }: { node: RoofNode }) => {
const ref = useRef<THREE.Group>(null!) const ref = useRef<THREE.Group>(null!)
@@ -24,6 +24,10 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => {
const handlers = useNodeEvents(node, 'roof') const handlers = useNodeEvents(node, 'roof')
const debugColors = useViewer((s) => s.debugColors) 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. // Collect roof element IDs (chimneys, skylights, etc.) hosted by any segment.
// Rendered outside segments-wrapper (invisible during normal mode) so elements // Rendered outside segments-wrapper (invisible during normal mode) so elements
@@ -80,9 +84,14 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => {
return geometry 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(() => { useEffect(() => {
return () => { return () => {
+46 -10
View File
@@ -1,18 +1,54 @@
import {
type ColorPreset,
createDefaultMaterial,
createSurfaceRoleMaterial,
type RenderShading,
} from '@pascal-app/viewer'
import * as THREE from 'three' import * as THREE from 'three'
// Production materials — match the rest of the scene (white walls, light-gray slabs). // Production materials — match the rest of the scene (white walls, light-gray slabs).
// Indices: 0 = Wall/Trim, 1 = Deck, 2 = Interior, 3 = Shingle // Indices: 0 = Wall/Trim, 1 = Deck, 2 = Interior, 3 = Shingle
export const roofMaterials: THREE.Material[] = [ const roofMaterialsCache = new Map<string, 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 export function getRoofMaterials(
new THREE.MeshStandardMaterial({ color: 'white', roughness: 1, side: THREE.DoubleSide }), // 2: Interior shading: RenderShading = 'rendered',
new THREE.MeshStandardMaterial({ color: '#e5e5e5', roughness: 0.9, side: THREE.FrontSide }), // 3: Shingle 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. // Debug materials — vivid, distinct colours to identify each surface group.
export const roofDebugMaterials: THREE.Material[] = [ const roofDebugMaterialsCache = new Map<RenderShading, 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 export function getRoofDebugMaterials(shading: RenderShading = 'rendered'): THREE.Material[] {
new THREE.MeshStandardMaterial({ color: '#dddddd', roughness: 0.9, side: THREE.DoubleSide }), // 2: Interior const cached = roofDebugMaterialsCache.get(shading)
new THREE.MeshStandardMaterial({ color: '#4ade80', roughness: 0.9, side: THREE.FrontSide }), // 3: Shingle 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
}
+6 -1
View File
@@ -29,7 +29,12 @@ function analyticalSurfaceY(seg: RoofSegmentNode, lx: number, lz: number): numbe
const peakY = seg.wallHeight + rh const peakY = seg.wallHeight + rh
if (rh === 0) return seg.wallHeight 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 const t = seg.depth > 0 ? Math.abs(lz) / (seg.depth / 2) : 0
return peakY - t * rh return peakY - t * rh
} }
+1
View File
@@ -10,6 +10,7 @@ export const shelfDefinition: NodeDefinition<typeof ShelfNode> = {
schemaVersion: 2, schemaVersion: 2,
schema: ShelfNode, schema: ShelfNode,
category: 'furnish', category: 'furnish',
surfaceRole: 'joinery',
defaults: () => ({ defaults: () => ({
object: 'node', object: 'node',
+28 -16
View File
@@ -1,10 +1,12 @@
import { getMaterialPresetByRef } from '@pascal-app/core' import { getMaterialPresetByRef } from '@pascal-app/core'
import { import {
applyMaterialPresetToMaterials, applyMaterialPresetToMaterials,
createDefaultMaterial,
createMaterial, createMaterial,
DEFAULT_SHELF_MATERIAL, DEFAULT_SHELF_MATERIAL,
type RenderShading,
} from '@pascal-app/viewer' } 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' 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 * Style dispatch lives at the top of the function; each style helper
* mutates the same `group`. * 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({ const cacheKey = JSON.stringify({
shading,
material: node.material ?? null, material: node.material ?? null,
materialPreset: node.materialPreset ?? null, materialPreset: node.materialPreset ?? null,
}) })
@@ -43,28 +50,33 @@ function getShelfMaterial(node: ShelfNode): MeshStandardMaterial {
const preset = getMaterialPresetByRef(node.materialPreset) const preset = getMaterialPresetByRef(node.materialPreset)
const material = preset const material = preset
? new MeshStandardMaterial() ? createDefaultMaterial('#ffffff', 0.5, shading)
: node.material : node.material
? createMaterial(node.material).clone() ? createMaterial(node.material, shading).clone()
: DEFAULT_SHELF_MATERIAL.clone() : DEFAULT_SHELF_MATERIAL(shading).clone()
if (preset) { if (preset) {
applyMaterialPresetToMaterials(material, preset) applyMaterialPresetToMaterials(material, preset)
} }
material.side = FrontSide const shelfMaterial = material as ShelfMaterial
material.depthWrite = true shelfMaterial.side = FrontSide
material.needsUpdate = true shelfMaterial.depthWrite = true
shelfMaterial.needsUpdate = true
shelfMaterialCache.set(cacheKey, material) shelfMaterialCache.set(cacheKey, material)
return material return material
} }
export function buildShelfGeometry(node: ShelfNode): Group { export function buildShelfGeometry(
node: ShelfNode,
_ctx?: unknown,
shading: RenderShading = 'rendered',
): Group {
const group = new Group() const group = new Group()
group.name = 'shelf-geometry' group.name = 'shelf-geometry'
const material = getShelfMaterial(node) const material = getShelfMaterial(node, shading)
switch (node.style) { switch (node.style) {
case 'wall-shelf': case 'wall-shelf':
@@ -91,7 +103,7 @@ export function buildShelfGeometry(node: ShelfNode): Group {
* evenly-spaced boards from `height/rows` up to `height`. Brackets * evenly-spaced boards from `height/rows` up to `height`. Brackets
* span from floor to the topmost board. * 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)) { for (const y of boardCenterYs(node)) {
const board = new Mesh(new BoxGeometry(node.width, node.thickness, node.depth), material) const board = new Mesh(new BoxGeometry(node.width, node.thickness, node.depth), material)
board.name = `shelf-board-${boardRowIndex(node, y)}` 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 * `withSides === false`, side panels become slim corner posts (a rack
* silhouette). * silhouette).
*/ */
function buildBookshelf(group: Group, node: ShelfNode, material: MeshStandardMaterial) { function buildBookshelf(group: Group, node: ShelfNode, material: Material) {
const unitHeight = node.height + node.thickness const unitHeight = node.height + node.thickness
const innerWidth = node.withSides ? node.width - 2 * node.thickness : node.width 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` * X-brace on the back face for stability. `withSides` / `bracketStyle`
* are ignored (the rack defines its own posts). * 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 unitHeight = node.height + node.thickness
const innerWidth = node.width const innerWidth = node.width
const boardThickness = Math.max(0.02, node.thickness * 0.8) 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 * boards + vertical dividers. `withBack` / `withSides` are forced on
* because the cubby shape requires them. * 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 unitHeight = node.height + node.thickness
const innerWidth = node.width - 2 * node.thickness const innerWidth = node.width - 2 * node.thickness
@@ -294,7 +306,7 @@ function boardRowIndex(node: ShelfNode, y: number): number {
function addCornerPosts( function addCornerPosts(
group: Group, group: Group,
node: ShelfNode, node: ShelfNode,
material: MeshStandardMaterial, material: Material,
unitHeight: number, unitHeight: number,
postStyle: 'rack' | 'leg', postStyle: 'rack' | 'leg',
) { ) {
+9 -7
View File
@@ -1,7 +1,8 @@
'use client' 'use client'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo } from 'react' import { useEffect, useMemo } from 'react'
import type { MeshStandardMaterial } from 'three' import type { Material } from 'three'
import { buildShelfGeometry } from './geometry' import { buildShelfGeometry } from './geometry'
import type { ShelfNode } from './schema' import type { ShelfNode } from './schema'
@@ -13,7 +14,7 @@ import type { ShelfNode } from './schema'
* the clone for a translucent ghost. * the clone for a translucent ghost.
* *
* Cloning is non-negotiable: `getShelfMaterial` caches the default * 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 * `material` / `materialPreset`, so every unpainted shelf in the scene
* shares the same material. Mutating `mat.transparent = true` here * shares the same material. Mutating `mat.transparent = true` here
* would leak into every committed shelf and render them all see-through. * 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. * to the grid plane below.
*/ */
const ShelfPreview = ({ node }: { node: ShelfNode }) => { 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(() => { useEffect(() => {
const cloned: MeshStandardMaterial[] = [] const cloned: Material[] = []
built.traverse((obj) => { built.traverse((obj) => {
// Skip pointer events: see component-level note above. // Skip pointer events: see component-level note above.
;(obj as unknown as { raycast: () => void }).raycast = () => {} ;(obj as unknown as { raycast: () => void }).raycast = () => {}
// `Mesh.material` is typed as `Material | Material[]` upstream; // `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 // `getShelfMaterial`. Access through a structural cast keeps the
// assignment well-typed without depending on the Mesh union. // assignment well-typed without depending on the Mesh union.
const mesh = obj as { const mesh = obj as {
material?: MeshStandardMaterial | MeshStandardMaterial[] material?: Material | Material[]
} }
if (!mesh.material) return if (!mesh.material) return
const cloneAndSwap = (mat: MeshStandardMaterial): MeshStandardMaterial => { const cloneAndSwap = (mat: Material): Material => {
const c = mat.clone() const c = mat.clone()
c.transparent = true c.transparent = true
c.opacity = 0.5 c.opacity = 0.5
+33 -11
View File
@@ -1,9 +1,22 @@
'use client' 'use client'
import { type AnyNodeId, type SiteNode, type SlabNode, useRegistry, useScene } from '@pascal-app/core' import {
import { NodeRenderer, unionPolygons, useNodeEvents, useViewer } from '@pascal-app/viewer' 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 { useMemo, useRef } from 'react'
import { BufferGeometry, Float32BufferAttribute, type Group, Path, Shape } from 'three' import { BufferGeometry, Float32BufferAttribute, type Group, Path, Shape } from 'three'
import { MeshLambertNodeMaterial } from 'three/webgpu'
const Y_OFFSET = 0.01 const Y_OFFSET = 0.01
@@ -37,8 +50,18 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
useRegistry(node.id, 'site', ref) useRegistry(node.id, 'site', ref)
const theme = useViewer((state) => state.theme) const bgColor = useViewer((state) => getSceneTheme(state.sceneTheme).ground)
const bgColor = theme === 'dark' ? '#1f2433' : '#fafafa'
// 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 // Cache slab polygon references to keep the selector stable across unrelated store updates
const slabPolygonsCache = useRef<[number, number][][]>([]) 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 */} {/* Ground fill: site polygon with slab holes, occludes below-grade geometry */}
{groundShape && ( {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]} /> <shapeGeometry args={[groundShape]} />
<meshBasicMaterial
color={bgColor}
polygonOffset={true}
polygonOffsetFactor={1}
polygonOffsetUnits={1}
/>
</mesh> </mesh>
)} )}
+2 -1
View File
@@ -2,8 +2,8 @@ import {
type AnyNode, type AnyNode,
type NodeDefinition, type NodeDefinition,
type RoofSegmentNode, type RoofSegmentNode,
type SkylightNode as SkylightNodeType,
SkylightNode as SkylightNodeSchema, SkylightNode as SkylightNodeSchema,
type SkylightNode as SkylightNodeType,
} from '@pascal-app/core' } from '@pascal-app/core'
import { import {
closeSkylightOpenState, closeSkylightOpenState,
@@ -25,6 +25,7 @@ export const skylightDefinition: NodeDefinition<typeof SkylightNode> = {
schemaVersion: 1, schemaVersion: 1,
schema: SkylightNode, schema: SkylightNode,
category: 'structure', category: 'structure',
surfaceRole: 'glazing',
defaults: () => { defaults: () => {
const stub = SkylightNodeSchema.parse({ const stub = SkylightNodeSchema.parse({
+84 -8
View File
@@ -27,16 +27,92 @@ export function buildLanternGlassGeometry(
const positions = const positions =
resolvedTopScale <= 1e-4 resolvedTopScale <= 1e-4
? [ ? [
-baseHalfW, 0, baseHalfD, baseHalfW, 0, baseHalfD, 0, topY, 0, -baseHalfW,
baseHalfW, 0, baseHalfD, baseHalfW, 0, -baseHalfD, 0, topY, 0, 0,
baseHalfW, 0, -baseHalfD, -baseHalfW, 0, -baseHalfD, 0, topY, 0, baseHalfD,
-baseHalfW, 0, -baseHalfD, -baseHalfW, 0, baseHalfD, 0, topY, 0, 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,
baseHalfW, 0, baseHalfD, baseHalfW, 0, -baseHalfD, topHalfW, topY, -topHalfD, topHalfW, topY, topHalfD, 0,
baseHalfW, 0, -baseHalfD, -baseHalfW, 0, -baseHalfD, -topHalfW, topY, -topHalfD, topHalfW, topY, -topHalfD, baseHalfD,
-baseHalfW, 0, -baseHalfD, -baseHalfW, 0, baseHalfD, -topHalfW, topY, topHalfD, -topHalfW, topY, -topHalfD, 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 = const indices =
resolvedTopScale <= 1e-4 resolvedTopScale <= 1e-4
+4 -14
View File
@@ -6,15 +6,11 @@ import {
type RoofEvent, type RoofEvent,
type RoofNode, type RoofNode,
type RoofSegmentNode, type RoofSegmentNode,
sceneRegistry,
type SkylightNode, type SkylightNode,
sceneRegistry,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
markToolCancelConsumed,
triggerSFX,
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
@@ -75,11 +71,7 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) {
const skylightObj = sceneRegistry.nodes.get(node.id) const skylightObj = sceneRegistry.nodes.get(node.id)
if (skylightObj) skylightObj.visible = false if (skylightObj) skylightObj.visible = false
const worldToBuildingLocal = ( const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => {
wx: number,
wy: number,
wz: number,
): [number, number, number] => {
const buildingId = useViewer.getState().selection.buildingId 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) { if (buildingObj) {
@@ -166,9 +158,7 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) {
}) })
if (original.roofSegmentId && original.roofSegmentId !== (targetSegmentId as string)) { if (original.roofSegmentId && original.roofSegmentId !== (targetSegmentId as string)) {
const oldSeg = st.nodes[original.roofSegmentId as AnyNodeId] as const oldSeg = st.nodes[original.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined
| RoofSegmentNode
| undefined
if (oldSeg) { if (oldSeg) {
st.updateNode(original.roofSegmentId as AnyNodeId, { st.updateNode(original.roofSegmentId as AnyNodeId, {
children: (oldSeg.children ?? []).filter((id) => id !== node.id), children: (oldSeg.children ?? []).filter((id) => id !== node.id),
+3 -3
View File
@@ -1,19 +1,18 @@
'use client' 'use client'
import { import {
SKYLIGHT_TYPE_ORDER,
SKYLIGHT_TYPE_PRESETS,
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
type RoofNode, type RoofNode,
type RoofSegmentNode, type RoofSegmentNode,
SKYLIGHT_TYPE_ORDER,
SKYLIGHT_TYPE_PRESETS,
type SkylightNode, type SkylightNode,
type SkylightType, type SkylightType,
sceneRegistry, sceneRegistry,
useLiveNodeOverrides, useLiveNodeOverrides,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { Vector3 } from 'three'
import { import {
ActionButton, ActionButton,
ActionGroup, ActionGroup,
@@ -26,6 +25,7 @@ import {
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Trash2 } from 'lucide-react' import { Trash2 } from 'lucide-react'
import { useCallback } from 'react' import { useCallback } from 'react'
import { Vector3 } from 'three'
const cn = (...classes: Array<string | false | undefined | null>): string => const cn = (...classes: Array<string | false | undefined | null>): string =>
classes.filter(Boolean).join(' ') classes.filter(Boolean).join(' ')
+39 -21
View File
@@ -12,16 +12,19 @@ import {
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { import {
type ColorPreset,
createMaterial, createMaterial,
createMaterialFromPresetRef, createMaterialFromPresetRef,
createSurfaceRoleMaterial,
getRoofOuterSurfaceFrameAtPoint, getRoofOuterSurfaceFrameAtPoint,
useNodeEvents, useNodeEvents,
useViewer,
} from '@pascal-app/viewer' } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef } from 'react' import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { buildLanternGlassGeometry, clamp01, paneSize } from './geometry'
import { buildFrameGeometry } from './frame-csg'
import { surfaceQuatFromNormal } from '../solar-panel/geometry' import { surfaceQuatFromNormal } from '../solar-panel/geometry'
import { buildFrameGeometry } from './frame-csg'
import { buildLanternGlassGeometry, clamp01, paneSize } from './geometry'
const defaultFrameMaterial = new THREE.MeshStandardMaterial({ const defaultFrameMaterial = new THREE.MeshStandardMaterial({
color: 0xff_ff_ff, color: 0xff_ff_ff,
@@ -569,6 +572,10 @@ const SkylightRenderer = ({ node: storeNode }: { node: SkylightNode }) => {
const ref = useRef<THREE.Group>(null!) const ref = useRef<THREE.Group>(null!)
useRegistry(storeNode.id, 'skylight', ref) useRegistry(storeNode.id, 'skylight', ref)
const handlers = useNodeEvents(storeNode, 'skylight') 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 liveOverrides = useLiveNodeOverrides((state) => state.get(storeNode.id))
const node = useMemo( const node = useMemo(
@@ -599,13 +606,15 @@ const SkylightRenderer = ({ node: storeNode }: { node: SkylightNode }) => {
} }
}, [frameGeo]) }, [frameGeo])
const frameMaterial = useMemo( const frameMaterial = useMemo(() => {
() => // Untextured frame (and everything in textures-off mode) takes the
node.material // themed 'joinery' role colour; explicit paint shows when textures on.
? createMaterial(node.material) if (!textures || (!node.material && !node.materialPreset)) {
: (createMaterialFromPresetRef(node.materialPreset) ?? defaultFrameMaterial), return createSurfaceRoleMaterial('joinery', colorPreset, undefined, sceneTheme)
[node.material, node.materialPreset], }
) 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 activeType = node.skylightType ?? 'flat'
const typePreset = SKYLIGHT_TYPE_PRESETS[activeType] const typePreset = SKYLIGHT_TYPE_PRESETS[activeType]
@@ -616,10 +625,16 @@ const SkylightRenderer = ({ node: storeNode }: { node: SkylightNode }) => {
const openAmount = runtimeOpenAmount ?? node.operationState ?? typePreset.operationState const openAmount = runtimeOpenAmount ?? node.operationState ?? typePreset.operationState
const glassMaterial = useMemo(() => { const glassMaterial = useMemo(() => {
const mat = // Untextured glass (and textures-off mode) takes the themed 'glazing'
node.glassMaterial // role material — already DoubleSide + semi-transparent, and shared
? createMaterial(node.glassMaterial) // from the cache, so it must not be mutated.
: (createMaterialFromPresetRef(node.glassMaterialPreset) ?? defaultGlassMaterial.clone()) 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') { if (mat && typeof mat === 'object') {
;(mat as THREE.Material).side = THREE.DoubleSide ;(mat as THREE.Material).side = THREE.DoubleSide
if (mat instanceof THREE.MeshPhysicalMaterial) { if (mat instanceof THREE.MeshPhysicalMaterial) {
@@ -627,16 +642,19 @@ const SkylightRenderer = ({ node: storeNode }: { node: SkylightNode }) => {
} }
} }
return mat return mat
}, [glassThickness, node.glassMaterial, node.glassMaterialPreset]) }, [
textures,
colorPreset,
sceneTheme,
shading,
glassThickness,
node.glassMaterial,
node.glassMaterialPreset,
])
const surfaceFrame = useMemo(() => { const surfaceFrame = useMemo(() => {
if (!segment) if (!segment) return { point: new THREE.Vector3(), normal: new THREE.Vector3(0, 1, 0) }
return { point: new THREE.Vector3(), normal: new THREE.Vector3(0, 1, 0) } return getRoofOuterSurfaceFrameAtPoint(segment, node.position[0] ?? 0, node.position[2] ?? 0)
return getRoofOuterSurfaceFrameAtPoint(
segment,
node.position[0] ?? 0,
node.position[2] ?? 0,
)
}, [segment, node.position[0], node.position[2], node.rotation, liveOverrides, storeNode.id]) }, [segment, node.position[0], node.position[2], node.rotation, liveOverrides, storeNode.id])
const surfaceQuat = useMemo( const surfaceQuat = useMemo(
+3 -7
View File
@@ -5,8 +5,8 @@ import {
emitter, emitter,
type RoofEvent, type RoofEvent,
type RoofNode, type RoofNode,
sceneRegistry,
SkylightNode, SkylightNode,
sceneRegistry,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { triggerSFX } from '@pascal-app/editor' import { triggerSFX } from '@pascal-app/editor'
@@ -14,8 +14,8 @@ import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { resolveRoofSegmentHit } from '../roof/segment-hit' import { resolveRoofSegmentHit } from '../roof/segment-hit'
import { skylightDefinition } from './definition'
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../solar-panel/geometry' import { getAnalyticalNormal, surfaceQuatFromNormal } from '../solar-panel/geometry'
import { skylightDefinition } from './definition'
import SkylightPreview from './preview' import SkylightPreview from './preview'
const worldPoint = new THREE.Vector3() const worldPoint = new THREE.Vector3()
@@ -43,11 +43,7 @@ const SkylightTool = () => {
useEffect(() => { useEffect(() => {
if (!activeBuildingId) return if (!activeBuildingId) return
const worldToBuildingLocal = ( const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => {
wx: number,
wy: number,
wz: number,
): [number, number, number] => {
const buildingObj = sceneRegistry.nodes.get(activeBuildingId as AnyNodeId) const buildingObj = sceneRegistry.nodes.get(activeBuildingId as AnyNodeId)
if (!buildingObj) return [wx, wy, wz] if (!buildingObj) return [wx, wy, wz]
worldPoint.set(wx, wy, wz) worldPoint.set(wx, wy, wz)
+1
View File
@@ -31,6 +31,7 @@ export const slabDefinition: NodeDefinition<typeof SlabNode> = {
schemaVersion: 1, schemaVersion: 1,
schema: SlabNode, schema: SlabNode,
category: 'structure', category: 'structure',
surfaceRole: 'floor',
defaults: () => ({ defaults: () => ({
object: 'node', object: 'node',
+49 -14
View File
@@ -1,11 +1,15 @@
import { getMaterialPresetByRef, type SlabNode } from '@pascal-app/core' import { getMaterialPresetByRef, type SlabNode } from '@pascal-app/core'
import { import {
applyMaterialPresetToMaterials, applyMaterialPresetToMaterials,
type ColorPreset,
createDefaultMaterial,
createMaterial, createMaterial,
createSurfaceRoleMaterial,
DEFAULT_SLAB_MATERIAL, DEFAULT_SLAB_MATERIAL,
generateSlabGeometry, generateSlabGeometry,
type RenderShading,
} from '@pascal-app/viewer' } 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 * 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 * (preset apply) is preserved — async texture loads still update the
* rendered material after re-mount. * 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({ const cacheKey = JSON.stringify({
shading,
material: node.material ?? null, material: node.material ?? null,
materialPreset: node.materialPreset ?? null, materialPreset: node.materialPreset ?? null,
}) })
@@ -29,33 +54,43 @@ function getSlabMaterial(node: SlabNode): MeshStandardMaterial {
const preset = getMaterialPresetByRef(node.materialPreset) const preset = getMaterialPresetByRef(node.materialPreset)
const material = preset const material = preset
? new MeshStandardMaterial() ? createDefaultMaterial('#ffffff', 0.5, shading)
: node.material : node.material
? createMaterial(node.material).clone() ? createMaterial(node.material, shading).clone()
: DEFAULT_SLAB_MATERIAL.clone() : DEFAULT_SLAB_MATERIAL(shading).clone()
if (preset) { if (preset) {
applyMaterialPresetToMaterials(material, preset) applyMaterialPresetToMaterials(material, preset)
} }
material.transparent = false const slabMaterial = material as SlabMaterial
material.opacity = 1 slabMaterial.transparent = false
material.alphaMap = null slabMaterial.opacity = 1
material.side = DoubleSide slabMaterial.alphaMap = null
material.depthWrite = true slabMaterial.side = DoubleSide
material.needsUpdate = true slabMaterial.depthWrite = true
slabMaterial.needsUpdate = true
slabMaterialCache.set(cacheKey, material) slabMaterialCache.set(cacheKey, material)
return 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 group = new Group()
const geometry = generateSlabGeometry(node) const geometry = generateSlabGeometry(node)
const material = getSlabMaterial(node) const material = getSlabMaterial(node, shading, textures, colorPreset, sceneTheme)
const mesh = new Mesh(geometry, material) const mesh = new Mesh(geometry, material)
mesh.castShadow = true mesh.castShadow = true
mesh.receiveShadow = true mesh.receiveShadow = true
const elevation = node.elevation ?? 0.05
if (elevation < 0) mesh.position.y = elevation
group.add(mesh) group.add(mesh)
return group return group
} }

Some files were not shown because too many files have changed in this diff Show More