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

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