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
@@ -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' })
+217 -79
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])
}
}
@@ -153,9 +146,9 @@ function roundedRectProfile(
const r = Math.max(0, Math.min(bevel, hw, hd))
// 4 corner centers, CCW from +X+Z (NE, NW, SW, SE).
const corners: Array<{ cx: number; cz: number; startAngle: number }> = [
{ cx: hw - r, cz: hd - r, startAngle: 0 }, // NE
{ cx: -(hw - r), cz: hd - r, startAngle: Math.PI / 2 }, // NW
{ cx: -(hw - r), cz: -(hd - r), startAngle: Math.PI }, // SW
{ cx: hw - r, cz: hd - r, startAngle: 0 }, // NE
{ cx: -(hw - r), cz: hd - r, startAngle: Math.PI / 2 }, // NW
{ cx: -(hw - r), cz: -(hd - r), startAngle: Math.PI }, // SW
{ cx: hw - r, cz: -(hd - r), startAngle: Math.PI * 1.5 }, // SE
]
const out: Array<{ x: number; z: number }> = []
@@ -231,27 +224,83 @@ function buildCapShape(node: BoxVentNode): THREE.BufferGeometry {
const uvs: number[] = []
// ── Body (4 walls + sealed bottom)
pushQuad(positions, normals, uvs,
[hw, 0, -hd], [hw, 0, hd], [hw, bodyH, hd], [hw, bodyH, -hd], [1, 0, 0])
pushQuad(positions, normals, uvs,
[-hw, 0, hd], [-hw, 0, -hd], [-hw, bodyH, -hd], [-hw, bodyH, hd], [-1, 0, 0])
pushQuad(positions, normals, uvs,
[hw, 0, hd], [-hw, 0, hd], [-hw, bodyH, hd], [hw, bodyH, hd], [0, 0, 1])
pushQuad(positions, normals, uvs,
[-hw, 0, -hd], [hw, 0, -hd], [hw, bodyH, -hd], [-hw, bodyH, -hd], [0, 0, -1])
pushQuad(positions, normals, uvs,
[-hw, 0, -hd], [-hw, 0, hd], [hw, 0, hd], [hw, 0, -hd], [0, -1, 0])
pushQuad(
positions,
normals,
uvs,
[hw, 0, -hd],
[hw, 0, hd],
[hw, bodyH, hd],
[hw, bodyH, -hd],
[1, 0, 0],
)
pushQuad(
positions,
normals,
uvs,
[-hw, 0, hd],
[-hw, 0, -hd],
[-hw, bodyH, -hd],
[-hw, bodyH, hd],
[-1, 0, 0],
)
pushQuad(
positions,
normals,
uvs,
[hw, 0, hd],
[-hw, 0, hd],
[-hw, bodyH, hd],
[hw, bodyH, hd],
[0, 0, 1],
)
pushQuad(
positions,
normals,
uvs,
[-hw, 0, -hd],
[hw, 0, -hd],
[hw, bodyH, -hd],
[-hw, bodyH, -hd],
[0, 0, -1],
)
pushQuad(
positions,
normals,
uvs,
[-hw, 0, -hd],
[-hw, 0, hd],
[hw, 0, hd],
[hw, 0, -hd],
[0, -1, 0],
)
// ── Body top (only when there's a visible gap to look through)
if (capGap > 0) {
pushQuad(positions, normals, uvs,
[-hw, bodyH, hd], [-hw, bodyH, -hd], [hw, bodyH, -hd], [hw, bodyH, hd], [0, 1, 0])
pushQuad(
positions,
normals,
uvs,
[-hw, bodyH, hd],
[-hw, bodyH, -hd],
[hw, bodyH, -hd],
[hw, bodyH, hd],
[0, 1, 0],
)
}
// ── Flange underside (the bit of the cap base that overhangs the body)
if (overhang > 0 || capGap > 0) {
pushQuad(positions, normals, uvs,
[-bw, y0, -bd], [-bw, y0, bd], [bw, y0, bd], [bw, y0, -bd], [0, -1, 0])
pushQuad(
positions,
normals,
uvs,
[-bw, y0, -bd],
[-bw, y0, bd],
[bw, y0, bd],
[bw, y0, -bd],
[0, -1, 0],
)
}
// ── 4 chamfered cap faces (trapezoids: wider at base, narrow at top).
@@ -260,23 +309,63 @@ function buildCapShape(node: BoxVentNode): THREE.BufferGeometry {
const dx = bw - tw // horizontal slope run on the X-facing faces
const dz = bd - td
// +X face
pushQuad(positions, normals, uvs,
[bw, y0, -bd], [bw, y0, bd], [tw, y1, td], [tw, y1, -td], [dx, capH, 0])
pushQuad(
positions,
normals,
uvs,
[bw, y0, -bd],
[bw, y0, bd],
[tw, y1, td],
[tw, y1, -td],
[dx, capH, 0],
)
// -X face
pushQuad(positions, normals, uvs,
[-bw, y0, bd], [-bw, y0, -bd], [-tw, y1, -td], [-tw, y1, td], [-dx, capH, 0])
pushQuad(
positions,
normals,
uvs,
[-bw, y0, bd],
[-bw, y0, -bd],
[-tw, y1, -td],
[-tw, y1, td],
[-dx, capH, 0],
)
// +Z face
pushQuad(positions, normals, uvs,
[bw, y0, bd], [-bw, y0, bd], [-tw, y1, td], [tw, y1, td], [0, capH, dz])
pushQuad(
positions,
normals,
uvs,
[bw, y0, bd],
[-bw, y0, bd],
[-tw, y1, td],
[tw, y1, td],
[0, capH, dz],
)
// -Z face
pushQuad(positions, normals, uvs,
[-bw, y0, -bd], [bw, y0, -bd], [tw, y1, -td], [-tw, y1, -td], [0, capH, -dz])
pushQuad(
positions,
normals,
uvs,
[-bw, y0, -bd],
[bw, y0, -bd],
[tw, y1, -td],
[-tw, y1, -td],
[0, capH, -dz],
)
// ── Flat closed top plane (no hollow opening — even if topTaper is 0,
// this collapses to the original body cross-section; if topTaper is 1
// it degenerates to a point and the four triangles meet, still closed).
pushQuad(positions, normals, uvs,
[-tw, y1, td], [-tw, y1, -td], [tw, y1, -td], [tw, y1, td], [0, 1, 0])
pushQuad(
positions,
normals,
uvs,
[-tw, y1, td],
[-tw, y1, -td],
[tw, y1, -td],
[tw, y1, td],
[0, 1, 0],
)
return buildBufferGeometry(positions, normals, uvs)
}
@@ -301,10 +390,12 @@ function buildDomeStyleShape(node: BoxVentNode): THREE.BufferGeometry {
const bodyH = h * 0.32
const hoodH = h - bodyH
return mergeGeometries(
[buildBody(w, d, bodyH), buildDomeHood(w, d, overhang, bodyH, hoodH, 'dome')],
false,
) ?? buildBody(w, d, bodyH)
return (
mergeGeometries(
[buildBody(w, d, bodyH), buildDomeHood(w, d, overhang, bodyH, hoodH, 'dome')],
false,
) ?? buildBody(w, d, bodyH)
)
}
// ─── Body ────────────────────────────────────────────────────────────
@@ -317,25 +408,60 @@ function buildBody(w: number, d: number, bodyH: number): THREE.BufferGeometry {
const uvs: number[] = []
// +X side
pushQuad(positions, normals, uvs,
[hw, 0, -hd], [hw, 0, hd], [hw, bodyH, hd], [hw, bodyH, -hd],
[1, 0, 0])
pushQuad(
positions,
normals,
uvs,
[hw, 0, -hd],
[hw, 0, hd],
[hw, bodyH, hd],
[hw, bodyH, -hd],
[1, 0, 0],
)
// -X side
pushQuad(positions, normals, uvs,
[-hw, 0, hd], [-hw, 0, -hd], [-hw, bodyH, -hd], [-hw, bodyH, hd],
[-1, 0, 0])
pushQuad(
positions,
normals,
uvs,
[-hw, 0, hd],
[-hw, 0, -hd],
[-hw, bodyH, -hd],
[-hw, bodyH, hd],
[-1, 0, 0],
)
// +Z side
pushQuad(positions, normals, uvs,
[hw, 0, hd], [-hw, 0, hd], [-hw, bodyH, hd], [hw, bodyH, hd],
[0, 0, 1])
pushQuad(
positions,
normals,
uvs,
[hw, 0, hd],
[-hw, 0, hd],
[-hw, bodyH, hd],
[hw, bodyH, hd],
[0, 0, 1],
)
// -Z side
pushQuad(positions, normals, uvs,
[-hw, 0, -hd], [hw, 0, -hd], [hw, bodyH, -hd], [-hw, bodyH, -hd],
[0, 0, -1])
pushQuad(
positions,
normals,
uvs,
[-hw, 0, -hd],
[hw, 0, -hd],
[hw, bodyH, -hd],
[-hw, bodyH, -hd],
[0, 0, -1],
)
// Bottom (closes the body so it reads as solid from below)
pushQuad(positions, normals, uvs,
[-hw, 0, -hd], [-hw, 0, hd], [hw, 0, hd], [hw, 0, -hd],
[0, -1, 0])
pushQuad(
positions,
normals,
uvs,
[-hw, 0, -hd],
[-hw, 0, hd],
[hw, 0, hd],
[hw, 0, -hd],
[0, -1, 0],
)
return buildBufferGeometry(positions, normals, uvs)
}
@@ -369,9 +495,16 @@ function buildDomeHood(
const y0 = bodyH
// Skirt underside
pushQuad(positions, normals, uvs,
[-bw, y0, -bd], [-bw, y0, bd], [bw, y0, bd], [bw, y0, -bd],
[0, -1, 0])
pushQuad(
positions,
normals,
uvs,
[-bw, y0, -bd],
[-bw, y0, bd],
[bw, y0, bd],
[bw, y0, -bd],
[0, -1, 0],
)
// Sample a low-resolution dome on a lat × lng grid. The radial decay
// is `cos(phi) ^ radialPower` — `radialPower < 1` keeps the dome wide
@@ -385,10 +518,10 @@ function buildDomeHood(
for (let i = 0; i <= lat; i++) {
const row: THREE.Vector3[] = []
const phi = (Math.PI / 2) * (i / lat)
const r = Math.pow(Math.cos(phi), radialPower)
const r = Math.cos(phi) ** radialPower
const y = y0 + hoodH * Math.sin(phi)
for (let j = 0; j <= lng; j++) {
const theta = (Math.PI * 2) * (j / lng)
const theta = Math.PI * 2 * (j / lng)
const x = bw * r * Math.cos(theta)
const z = bd * r * Math.sin(theta)
row.push(new THREE.Vector3(x, y, z))
@@ -410,9 +543,16 @@ function buildDomeHood(
// winding (see note in `pushQuad`). Swapping the cross operands here
// keeps the dome lit from the outside, not from inside.
const n = new THREE.Vector3().crossVectors(ad, ab).normalize()
pushQuad(positions, normals, uvs,
[a.x, a.y, a.z], [b.x, b.y, b.z], [c.x, c.y, c.z], [d2.x, d2.y, d2.z],
[n.x, n.y, n.z])
pushQuad(
positions,
normals,
uvs,
[a.x, a.y, a.z],
[b.x, b.y, b.z],
[c.x, c.y, c.z],
[d2.x, d2.y, d2.z],
[n.x, n.y, n.z],
)
}
}
@@ -514,9 +654,7 @@ function pushTri(
* source of truth.
*/
export function computeBoxVentSlopeTilt(
segment:
| { roofType: RoofType; pitch: number; width: number; depth: number }
| undefined,
segment: { roofType: RoofType; pitch: number; width: number; depth: number } | undefined,
localZ: number,
): number {
if (!segment || segment.roofType === 'flat' || localZ === 0) return 0
+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>
)
+41 -25
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,20 +76,23 @@ const BoxVentRenderer = ({ node: storeNode }: { node: BoxVentNode }) => {
// every parametric field, including the per-style ones. Listing them
// explicitly keeps the dep array tight (vs. `[node]` which would
// also fire on `name` / `visible` flips).
const geometry = useMemo(() => buildBoxVentGeometry(node), [
node.style,
node.width,
node.depth,
node.height,
node.hoodOverhang,
node.topTaper,
node.capHeight,
node.capGap,
node.domeCurvature,
node.baseInset,
node.baseHeight,
node.cornerBevel,
])
const geometry = useMemo(
() => buildBoxVentGeometry(node),
[
node.style,
node.width,
node.depth,
node.height,
node.hoodOverhang,
node.topTaper,
node.capHeight,
node.capGap,
node.domeCurvature,
node.baseInset,
node.baseHeight,
node.cornerBevel,
],
)
useEffect(() => () => geometry.dispose(), [geometry])
@@ -89,11 +103,7 @@ const BoxVentRenderer = ({ node: storeNode }: { node: BoxVentNode }) => {
// ran along segment-local Z.
const surfaceQuat = useMemo(() => {
if (!segment) return new THREE.Quaternion()
const normal = getAnalyticalNormal(
node.position[0] ?? 0,
node.position[2] ?? 0,
segment,
)
const normal = getAnalyticalNormal(node.position[0] ?? 0, node.position[2] ?? 0, segment)
return surfaceQuatFromNormal(normal, new THREE.Quaternion())
}, [segment, node.position[0], node.position[2]])
@@ -103,14 +113,20 @@ const BoxVentRenderer = ({ node: storeNode }: { node: BoxVentNode }) => {
// DoubleSide locally so back faces of the vent body / hood don't drop
// out when the camera looks up at the eaves.
const material = useMemo(() => {
// Untextured box vent (and textures-off mode) takes the themed 'roof'
// role colour. Request DoubleSide directly so the cached role material
// is the right side — no clone/mutation of a shared material.
if (!textures || (!node.material && !node.materialPreset)) {
return createSurfaceRoleMaterial('roof', colorPreset, THREE.DoubleSide, sceneTheme)
}
const base = node.material
? createMaterial(node.material)
: (createMaterialFromPresetRef(node.materialPreset) ?? defaultMaterial)
? createMaterial(node.material, shading)
: (createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial)
if (base.side === THREE.DoubleSide) return base
const cloned = base.clone()
cloned.side = THREE.DoubleSide
return cloned
}, [node.material, node.materialPreset])
}, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset])
if (!segment) return null
+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">
+34 -37
View File
@@ -21,43 +21,40 @@ const ghostMaterial = new THREE.MeshStandardMaterial({
* segment is hit, the preview isn't shown at all (the tool guards on
* `previewPos`).
*/
const ChimneyPreview = ({
node,
segment,
}: {
node: ChimneyNode
segment: RoofSegmentNode
}) => {
const geo = useMemo(() => buildChimneyGeometry(node, segment), [
segment.wallHeight,
segment.pitch,
segment.roofType,
segment.width,
segment.depth,
node.width,
node.depth,
node.heightAboveRidge,
node.bodyShape,
node.shoulderStyle,
node.shoulderHeight,
node.shoulderExtent,
node.cap,
node.capShape,
node.capOverhang,
node.capThickness,
node.flueCount,
node.flueShape,
node.flueHeight,
node.flueDiameter,
node.flueSpacing,
node.cricketStyle,
node.cricketSide,
node.cricketLength,
node.cricketHeight,
node.position[0],
node.position[2],
node.rotation,
])
const ChimneyPreview = ({ node, segment }: { node: ChimneyNode; segment: RoofSegmentNode }) => {
const geo = useMemo(
() => buildChimneyGeometry(node, segment),
[
segment.wallHeight,
segment.pitch,
segment.roofType,
segment.width,
segment.depth,
node.width,
node.depth,
node.heightAboveRidge,
node.bodyShape,
node.shoulderStyle,
node.shoulderHeight,
node.shoulderExtent,
node.cap,
node.capShape,
node.capOverhang,
node.capThickness,
node.flueCount,
node.flueShape,
node.flueHeight,
node.flueDiameter,
node.flueSpacing,
node.cricketStyle,
node.cricketSide,
node.cricketLength,
node.cricketHeight,
node.position[0],
node.position[2],
node.rotation,
],
)
useEffect(
() => () => {
+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',
+78 -91
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,42 +97,50 @@ const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => {
node.wallMaterialPreset,
])
const material = resolvedMaterials ?? defaultDormerMaterials
const frameSideMat = resolvedMaterials ? resolvedMaterials[1]! : defaultSideMat
// The window frame bars / sill take the 'joinery' role when untextured;
// otherwise the deck-side material (slot 1) drives the frame look.
const frameSideMat = useMemo(() => {
if (!textures) return createSurfaceRoleMaterial('joinery', colorPreset, undefined, sceneTheme)
return material[1]!
}, [textures, colorPreset, sceneTheme, material])
const geometry = useMemo(
() => {
if (!segment) return null
if (isLiveDrag) return buildDormerFallbackGeometry(node)
return generateDormerGeometry(node, segment)
},
[
isLiveDrag,
segment,
node.id,
node.roofType,
node.width,
node.depth,
node.height,
node.roofHeight,
node.wallSkirtHeight,
node.position[0],
node.position[1],
node.position[2],
node.rotation,
node.windowWidth,
node.windowHeight,
node.windowOffsetX,
node.windowOffsetY,
node.windowShape,
node.windowArchHeight,
node.windowCornerRadii[0],
node.windowCornerRadii[1],
node.windowCornerRadii[2],
node.windowCornerRadii[3],
],
// Dormer window glass has no per-node material — it always takes the
// themed 'glazing' role (semi-transparent) in both texture modes.
const glassMat = useMemo(
() => createSurfaceRoleMaterial('glazing', colorPreset, undefined, sceneTheme),
[colorPreset, sceneTheme],
)
const geometry = useMemo(() => {
if (!segment) return null
if (isLiveDrag) return buildDormerFallbackGeometry(node)
return generateDormerGeometry(node, segment)
}, [
isLiveDrag,
segment,
node.id,
node.roofType,
node.width,
node.depth,
node.height,
node.roofHeight,
node.wallSkirtHeight,
node.position[0],
node.position[1],
node.position[2],
node.rotation,
node.windowWidth,
node.windowHeight,
node.windowOffsetX,
node.windowOffsetY,
node.windowShape,
node.windowArchHeight,
node.windowCornerRadii[0],
node.windowCornerRadii[1],
node.windowCornerRadii[2],
node.windowCornerRadii[3],
])
useEffect(() => () => geometry?.dispose(), [geometry])
if (!(segment && geometry)) return null
@@ -171,9 +159,7 @@ const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => {
rotation-y={segment.rotation ?? 0}
visible={node.visible}
>
<group
position={[node.position[0] ?? 0, node.position[1] ?? 0, node.position[2] ?? 0]}
>
<group position={[node.position[0] ?? 0, node.position[1] ?? 0, node.position[2] ?? 0]}>
<group rotation-y={node.rotation ?? 0} {...handlers}>
<mesh
castShadow
@@ -184,6 +170,7 @@ const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => {
/>
<DormerWindowAssembly
frameMaterial={frameSideMat}
glassMaterial={glassMat}
node={node}
segment={segment}
/>
+1 -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' })
+350 -179
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({
color: GLASS_COLOR,
depthWrite: false,
metalness: 0,
opacity: 0.2,
roughness: 0.08,
transparent: true,
})
const PANEL_MATERIAL = new MeshStandardMaterial({
color: PANEL_COLOR,
metalness: 0.32,
roughness: 0.36,
})
const LANDING_PANEL_MATERIAL = new MeshStandardMaterial({
color: PANEL_COLOR,
metalness: 0.25,
roughness: 0.4,
})
const INDICATOR_SCREEN_MATERIALS = {
active: new MeshStandardMaterial({
color: '#041f2f',
emissive: '#0ea5e9',
emissiveIntensity: 0.16,
metalness: 0.12,
roughness: 0.38,
}),
idle: new MeshStandardMaterial({
color: '#111827',
metalness: 0.12,
roughness: 0.38,
}),
type ElevatorMaterial = Material & {
depthWrite?: boolean
emissive?: { set: (color: string) => void }
emissiveIntensity?: number
metalness?: number
opacity?: number
roughness?: number
transparent?: boolean
}
const INDICATOR_GLYPH_MATERIALS = {
active: new MeshStandardMaterial({
color: '#38bdf8',
emissive: '#38bdf8',
emissiveIntensity: 0.36,
metalness: 0.08,
roughness: 0.32,
}),
idle: new MeshStandardMaterial({
color: '#94a3b8',
emissive: '#94a3b8',
emissiveIntensity: 0.18,
metalness: 0.08,
roughness: 0.32,
}),
type ElevatorMaterialParams = {
color: string
depthWrite?: boolean
emissive?: string
emissiveIntensity?: number
metalness?: number
opacity?: number
roughness?: number
transparent?: boolean
}
const BUTTON_FACE_MATERIALS = {
active: new MeshStandardMaterial({
color: '#38bdf8',
emissive: '#38bdf8',
emissiveIntensity: 0.28,
metalness: 0.22,
roughness: 0.3,
}),
queued: new MeshStandardMaterial({
color: '#fbbf24',
emissive: '#fbbf24',
emissiveIntensity: 0.18,
metalness: 0.22,
roughness: 0.3,
}),
idle: new MeshStandardMaterial({
color: '#d6dde7',
metalness: 0.22,
roughness: 0.3,
}),
disabled: new MeshStandardMaterial({
color: '#475569',
metalness: 0.12,
roughness: 0.52,
}),
function createElevatorMaterial(params: ElevatorMaterialParams, shading: RenderShading): Material {
const material = createDefaultMaterial(
params.color,
params.roughness ?? 0.5,
shading,
) as ElevatorMaterial
if ('metalness' in material) material.metalness = params.metalness ?? 0
if ('roughness' in material && params.roughness !== undefined) {
material.roughness = params.roughness
}
if (params.depthWrite !== undefined) material.depthWrite = params.depthWrite
if (params.opacity !== undefined) material.opacity = params.opacity
if (params.transparent !== undefined) material.transparent = params.transparent
if (params.emissive && material.emissive) material.emissive.set(params.emissive)
if ('emissiveIntensity' in material && params.emissiveIntensity !== undefined) {
material.emissiveIntensity = params.emissiveIntensity
}
material.needsUpdate = true
return material
}
const BUTTON_RING_MATERIALS = {
active: new MeshStandardMaterial({
color: '#0ea5e9',
emissive: '#0ea5e9',
emissiveIntensity: 0.16,
metalness: 0.48,
roughness: 0.28,
}),
queued: new MeshStandardMaterial({
color: '#f59e0b',
emissive: '#f59e0b',
emissiveIntensity: 0.1,
metalness: 0.48,
roughness: 0.28,
}),
idle: new MeshStandardMaterial({
color: '#64748b',
metalness: 0.48,
roughness: 0.28,
}),
disabled: new MeshStandardMaterial({
color: '#334155',
metalness: 0.28,
roughness: 0.5,
}),
function createElevatorMaterials(
shading: RenderShading,
textures = true,
colorPreset: ColorPreset = 'clay',
) {
if (!textures) {
const material = createSurfaceRoleMaterial('joinery', colorPreset)
return {
SHAFT_WALL_MATERIAL: material,
SHAFT_SIDE_MATERIAL: material,
SHAFT_TRIM_MATERIAL: material,
CAB_MATERIAL: material,
DOOR_MATERIAL: material,
DOOR_GROOVE_MATERIAL: material,
GLASS_MATERIAL: material,
PANEL_MATERIAL: material,
LANDING_PANEL_MATERIAL: material,
INDICATOR_SCREEN_MATERIALS: {
active: material,
idle: material,
},
INDICATOR_GLYPH_MATERIALS: {
active: material,
idle: material,
},
BUTTON_FACE_MATERIALS: {
active: material,
queued: material,
idle: material,
disabled: material,
},
BUTTON_RING_MATERIALS: {
active: material,
queued: material,
idle: material,
disabled: material,
},
BUTTON_GLOW_MATERIALS: {
active: material,
queued: material,
},
BUTTON_LABEL_MATERIALS: {
lit: material,
idle: material,
disabled: material,
},
QUEUE_STRIP_MATERIALS: {
queued: material,
idle: material,
},
}
}
return {
SHAFT_WALL_MATERIAL: createElevatorMaterial(
{ color: SHAFT_WALL_COLOR, metalness: 0.08, roughness: 0.56 },
shading,
),
SHAFT_SIDE_MATERIAL: createElevatorMaterial(
{ color: SHAFT_SIDE_COLOR, metalness: 0.12, roughness: 0.58 },
shading,
),
SHAFT_TRIM_MATERIAL: createElevatorMaterial(
{ color: SHAFT_TRIM_COLOR, metalness: 0.2, roughness: 0.38 },
shading,
),
CAB_MATERIAL: createElevatorMaterial(
{ color: CAB_COLOR, metalness: 0.2, roughness: 0.48 },
shading,
),
DOOR_MATERIAL: createElevatorMaterial(
{ color: DOOR_COLOR, metalness: 0.34, roughness: 0.34 },
shading,
),
DOOR_GROOVE_MATERIAL: createElevatorMaterial(
{ color: '#5f6978', metalness: 0.28, roughness: 0.42 },
shading,
),
GLASS_MATERIAL: createElevatorMaterial(
{
color: GLASS_COLOR,
depthWrite: false,
metalness: 0,
opacity: 0.2,
roughness: 0.08,
transparent: true,
},
shading,
),
PANEL_MATERIAL: createElevatorMaterial(
{ color: PANEL_COLOR, metalness: 0.32, roughness: 0.36 },
shading,
),
LANDING_PANEL_MATERIAL: createElevatorMaterial(
{ color: PANEL_COLOR, metalness: 0.25, roughness: 0.4 },
shading,
),
INDICATOR_SCREEN_MATERIALS: {
active: createElevatorMaterial(
{
color: '#041f2f',
emissive: '#0ea5e9',
emissiveIntensity: 0.16,
metalness: 0.12,
roughness: 0.38,
},
shading,
),
idle: createElevatorMaterial({ color: '#111827', metalness: 0.12, roughness: 0.38 }, shading),
},
INDICATOR_GLYPH_MATERIALS: {
active: createElevatorMaterial(
{
color: '#38bdf8',
emissive: '#38bdf8',
emissiveIntensity: 0.36,
metalness: 0.08,
roughness: 0.32,
},
shading,
),
idle: createElevatorMaterial(
{
color: '#94a3b8',
emissive: '#94a3b8',
emissiveIntensity: 0.18,
metalness: 0.08,
roughness: 0.32,
},
shading,
),
},
BUTTON_FACE_MATERIALS: {
active: createElevatorMaterial(
{
color: '#38bdf8',
emissive: '#38bdf8',
emissiveIntensity: 0.28,
metalness: 0.22,
roughness: 0.3,
},
shading,
),
queued: createElevatorMaterial(
{
color: '#fbbf24',
emissive: '#fbbf24',
emissiveIntensity: 0.18,
metalness: 0.22,
roughness: 0.3,
},
shading,
),
idle: createElevatorMaterial({ color: '#d6dde7', metalness: 0.22, roughness: 0.3 }, shading),
disabled: createElevatorMaterial(
{ color: '#475569', metalness: 0.12, roughness: 0.52 },
shading,
),
},
BUTTON_RING_MATERIALS: {
active: createElevatorMaterial(
{
color: '#0ea5e9',
emissive: '#0ea5e9',
emissiveIntensity: 0.16,
metalness: 0.48,
roughness: 0.28,
},
shading,
),
queued: createElevatorMaterial(
{
color: '#f59e0b',
emissive: '#f59e0b',
emissiveIntensity: 0.1,
metalness: 0.48,
roughness: 0.28,
},
shading,
),
idle: createElevatorMaterial({ color: '#64748b', metalness: 0.48, roughness: 0.28 }, shading),
disabled: createElevatorMaterial(
{ color: '#334155', metalness: 0.28, roughness: 0.5 },
shading,
),
},
BUTTON_GLOW_MATERIALS: {
active: createElevatorMaterial(
{
color: '#38bdf8',
depthWrite: false,
emissive: '#38bdf8',
emissiveIntensity: 0.28,
opacity: 0.58,
transparent: true,
},
shading,
),
queued: createElevatorMaterial(
{
color: '#fbbf24',
depthWrite: false,
emissive: '#fbbf24',
emissiveIntensity: 0.18,
opacity: 0.58,
transparent: true,
},
shading,
),
},
BUTTON_LABEL_MATERIALS: {
lit: createElevatorMaterial({ color: '#111827', metalness: 0.12, roughness: 0.34 }, shading),
idle: createElevatorMaterial({ color: '#334155', metalness: 0.12, roughness: 0.34 }, shading),
disabled: createElevatorMaterial(
{ color: '#94a3b8', metalness: 0.08, roughness: 0.5 },
shading,
),
},
QUEUE_STRIP_MATERIALS: {
queued: createElevatorMaterial(
{
color: '#fbbf24',
emissive: '#fbbf24',
emissiveIntensity: 0.16,
metalness: 0.18,
roughness: 0.42,
},
shading,
),
idle: createElevatorMaterial({ color: '#64748b', metalness: 0.18, roughness: 0.42 }, shading),
},
}
}
const BUTTON_GLOW_MATERIALS = {
active: new MeshStandardMaterial({
color: '#38bdf8',
depthWrite: false,
emissive: '#38bdf8',
emissiveIntensity: 0.28,
opacity: 0.58,
transparent: true,
}),
queued: new MeshStandardMaterial({
color: '#fbbf24',
depthWrite: false,
emissive: '#fbbf24',
emissiveIntensity: 0.18,
opacity: 0.58,
transparent: true,
}),
type ElevatorMaterialSet = ReturnType<typeof createElevatorMaterials>
const elevatorMaterialsCache = new Map<string, ElevatorMaterialSet>()
function getElevatorMaterials(
shading: RenderShading,
textures = true,
colorPreset: ColorPreset = 'clay',
): ElevatorMaterialSet {
const cacheKey = `${shading}-${textures}-${colorPreset}`
const cached = elevatorMaterialsCache.get(cacheKey)
if (cached) return cached
const materials = createElevatorMaterials(shading, textures, colorPreset)
elevatorMaterialsCache.set(cacheKey, materials)
return materials
}
const BUTTON_LABEL_MATERIALS = {
lit: new MeshStandardMaterial({
color: '#111827',
metalness: 0.12,
roughness: 0.34,
}),
idle: new MeshStandardMaterial({
color: '#334155',
metalness: 0.12,
roughness: 0.34,
}),
disabled: new MeshStandardMaterial({
color: '#94a3b8',
metalness: 0.08,
roughness: 0.5,
}),
}
const QUEUE_STRIP_MATERIALS = {
queued: new MeshStandardMaterial({
color: '#fbbf24',
emissive: '#fbbf24',
emissiveIntensity: 0.16,
metalness: 0.18,
roughness: 0.42,
}),
idle: new MeshStandardMaterial({
color: '#64748b',
metalness: 0.18,
roughness: 0.42,
}),
let {
SHAFT_WALL_MATERIAL,
SHAFT_SIDE_MATERIAL,
SHAFT_TRIM_MATERIAL,
CAB_MATERIAL,
DOOR_MATERIAL,
DOOR_GROOVE_MATERIAL,
GLASS_MATERIAL,
PANEL_MATERIAL,
LANDING_PANEL_MATERIAL,
INDICATOR_SCREEN_MATERIALS,
INDICATOR_GLYPH_MATERIALS,
BUTTON_FACE_MATERIALS,
BUTTON_RING_MATERIALS,
BUTTON_GLOW_MATERIALS,
BUTTON_LABEL_MATERIALS,
QUEUE_STRIP_MATERIALS,
} = getElevatorMaterials('rendered')
function setElevatorMaterials(
shading: RenderShading,
textures = true,
colorPreset: ColorPreset = 'clay',
) {
;({
SHAFT_WALL_MATERIAL,
SHAFT_SIDE_MATERIAL,
SHAFT_TRIM_MATERIAL,
CAB_MATERIAL,
DOOR_MATERIAL,
DOOR_GROOVE_MATERIAL,
GLASS_MATERIAL,
PANEL_MATERIAL,
LANDING_PANEL_MATERIAL,
INDICATOR_SCREEN_MATERIALS,
INDICATOR_GLYPH_MATERIALS,
BUTTON_FACE_MATERIALS,
BUTTON_RING_MATERIALS,
BUTTON_GLOW_MATERIALS,
BUTTON_LABEL_MATERIALS,
QUEUE_STRIP_MATERIALS,
} = getElevatorMaterials(shading, textures, colorPreset))
}
type ElevatorButtonAction = 'open-door' | 'request-level'
@@ -285,7 +451,7 @@ function BoxPrimitive({
scale,
}: {
castShadow?: boolean
material: MeshStandardMaterial
material: Material
position?: Vector3Tuple
receiveShadow?: boolean
rotation?: Vector3Tuple
@@ -314,7 +480,7 @@ function MeshButtonLabel({
}: {
faceSign?: -1 | 1
label: string
material: MeshStandardMaterial
material: Material
position: [number, number, number]
scale: number
}) {
@@ -381,7 +547,7 @@ function ElevatorDirectionGlyph({
scale,
}: {
direction: 'down' | 'up' | null
material: MeshStandardMaterial
material: Material
position: [number, number, number]
scale: number
}) {
@@ -484,7 +650,7 @@ function DoorOpenGlyph({
positionZ,
scale,
}: {
material: MeshStandardMaterial
material: Material
positionZ: number
scale: number
}) {
@@ -950,6 +1116,9 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => {
const ref = useRef<Group>(null!)
const cabRef = useRef<Group>(null)
const handlers = useNodeEvents(node, 'elevator')
const shading = useViewer((state) => state.shading)
const textures = useViewer((state) => state.textures)
const colorPreset = useViewer((state) => state.colorPreset)
const liveOverrides = useLiveNodeOverrides((state) => state.get(node.id))
const liveTransform = useLiveTransforms((state) => state.get(node.id))
const renderNode = useMemo(
@@ -960,6 +1129,8 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => {
useShallow((state) => getElevatorLevelContextNodes(renderNode, state.nodes)),
)
setElevatorMaterials(shading, textures, colorPreset)
useRegistry(node.id, 'elevator', ref)
const { entries, defaultEntry, shaftBaseY, totalHeight } = useMemo(
+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 () => {
+48 -12
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(' ')
+83 -65
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(
@@ -662,51 +680,51 @@ const SkylightRenderer = ({ node: storeNode }: { node: SkylightNode }) => {
<group position={[node.position[0] ?? 0, surfaceY, node.position[2] ?? 0]}>
<group quaternion={surfaceQuat}>
<group rotation-y={node.rotation ?? 0}>
<mesh
castShadow
geometry={frameGeo}
material={frameMaterial}
name="skylight-surface"
receiveShadow
/>
{activeType === 'lantern' && (
<LanternGlass
curbHeight={curbH}
frameMaterial={frameMaterial}
glassMaterial={glassMaterial}
node={node}
<mesh
castShadow
geometry={frameGeo}
material={frameMaterial}
name="skylight-surface"
receiveShadow
/>
)}
{activeType === 'sliding' && (
<SlidingGlass
curbHeight={curbH}
frameMaterial={frameMaterial}
glassMaterial={glassMaterial}
glassThickness={glassThickness}
node={node}
openAmount={openAmount}
/>
)}
{activeType === 'opening' && (
<HingedGlass
curbHeight={curbH}
frameMaterial={frameMaterial}
glassMaterial={glassMaterial}
glassThickness={glassThickness}
hasMotorHousing={node.motorHousing ?? false}
node={node}
openAmount={openAmount}
/>
)}
{(activeType === 'flat' || activeType === 'walk-on') && (
<GlassPane
glassThickness={glassThickness}
material={glassMaterial}
paneDepth={node.height + 0.004}
position={[0, curbH + glassThickness / 2, 0]}
width={node.width + 0.004}
/>
)}
{activeType === 'lantern' && (
<LanternGlass
curbHeight={curbH}
frameMaterial={frameMaterial}
glassMaterial={glassMaterial}
node={node}
/>
)}
{activeType === 'sliding' && (
<SlidingGlass
curbHeight={curbH}
frameMaterial={frameMaterial}
glassMaterial={glassMaterial}
glassThickness={glassThickness}
node={node}
openAmount={openAmount}
/>
)}
{activeType === 'opening' && (
<HingedGlass
curbHeight={curbH}
frameMaterial={frameMaterial}
glassMaterial={glassMaterial}
glassThickness={glassThickness}
hasMotorHousing={node.motorHousing ?? false}
node={node}
openAmount={openAmount}
/>
)}
{(activeType === 'flat' || activeType === 'walk-on') && (
<GlassPane
glassThickness={glassThickness}
material={glassMaterial}
paneDepth={node.height + 0.004}
position={[0, curbH + glassThickness / 2, 0]}
width={node.width + 0.004}
/>
)}
</group>
</group>
</group>
+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
}
@@ -18,6 +18,7 @@ export const solarPanelDefinition: NodeDefinition<typeof SolarPanelNode> = {
schemaVersion: 1,
schema: SolarPanelNode,
category: 'structure',
surfaceRole: 'roof',
defaults: () => {
const stub = SolarPanelNodeSchema.parse({
+10 -7
View File
@@ -106,8 +106,15 @@ export function getDefaultPanelMaterial(): THREE.Material {
*/
export function buildSolarPanelGeometry(node: SolarPanelNode): THREE.BufferGeometry | null {
const {
rows, columns, panelWidth, panelHeight, gapX, gapY,
frameThickness, frameDepth, standoffHeight,
rows,
columns,
panelWidth,
panelHeight,
gapX,
gapY,
frameThickness,
frameDepth,
standoffHeight,
} = node
const frameGeos: THREE.BufferGeometry[] = []
@@ -210,11 +217,7 @@ export function getSurfaceY(lx: number, lz: number, seg: RoofSegmentNode): numbe
return peakY - t * rh
}
export function getAnalyticalNormal(
lx: number,
lz: number,
seg: RoofSegmentNode,
): THREE.Vector3 {
export function getAnalyticalNormal(lx: number, lz: number, seg: RoofSegmentNode): THREE.Vector3 {
const { roofType, depth, width } = seg
const rh = getActiveRoofHeight(seg)
if (rh === 0) return new THREE.Vector3(0, 1, 0)
+5 -20
View File
@@ -6,16 +6,11 @@ import {
type RoofEvent,
type RoofNode,
type RoofSegmentNode,
sceneRegistry,
type SolarPanelNode,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import {
EDITOR_LAYER,
markToolCancelConsumed,
triggerSFX,
useEditor,
} from '@pascal-app/editor'
import { EDITOR_LAYER, markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import * as THREE from 'three'
@@ -91,11 +86,7 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) {
const panelObj = sceneRegistry.nodes.get(node.id)
if (panelObj) panelObj.visible = false
const worldToBuildingLocal = (
wx: number,
wy: number,
wz: number,
): [number, number, number] => {
const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => {
const buildingId = useViewer.getState().selection.buildingId
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
if (buildingObj) {
@@ -180,9 +171,7 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) {
})
if (original.roofSegmentId && original.roofSegmentId !== (targetSegmentId as string)) {
const oldSeg = st.nodes[original.roofSegmentId as AnyNodeId] as
| RoofSegmentNode
| undefined
const oldSeg = st.nodes[original.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined
if (oldSeg) {
st.updateNode(original.roofSegmentId as AnyNodeId, {
children: (oldSeg.children ?? []).filter((id) => id !== node.id),
@@ -275,11 +264,7 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) {
<group position={previewPos} ref={previewRef} visible={hasHit}>
<group rotation-y={previewYaw}>
<group quaternion={previewSurfaceQuat}>
<mesh
geometry={previewGeo}
layers={EDITOR_LAYER}
material={previewMaterial}
/>
<mesh geometry={previewGeo} layers={EDITOR_LAYER} material={previewMaterial} />
</group>
</group>
</group>
+7 -10
View File
@@ -65,9 +65,12 @@ export default function SolarPanelPanel() {
selectedId ? (s.nodes[selectedId as AnyNode['id']] as SolarPanelNode | undefined) : undefined,
)
const overrides = useLiveNodeOverrides((s) =>
selectedId ? (s.get(selectedId as AnyNodeId) as Partial<SolarPanelNode> | undefined) : undefined,
selectedId
? (s.get(selectedId as AnyNodeId) as Partial<SolarPanelNode> | undefined)
: undefined,
)
const node = storeNode && overrides ? ({ ...storeNode, ...overrides } as SolarPanelNode) : storeNode
const node =
storeNode && overrides ? ({ ...storeNode, ...overrides } as SolarPanelNode) : storeNode
const segment = useScene((s) =>
node?.roofSegmentId
? (s.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined)
@@ -267,15 +270,9 @@ export default function SolarPanelPanel() {
value={Math.round(num(node.gapY, 0.02) * 1000) / 1000}
/>
<ActionGroup>
<ActionButton
disabled={!segment}
label="Auto-fit to roof"
onClick={handleAutoFit}
/>
<ActionButton disabled={!segment} label="Auto-fit to roof" onClick={handleAutoFit} />
</ActionGroup>
{autoFitMessage ? (
<p className="px-1 text-amber-400 text-xs">{autoFitMessage}</p>
) : null}
{autoFitMessage ? <p className="px-1 text-amber-400 text-xs">{autoFitMessage}</p> : null}
</PanelSection>
<PanelSection title="Panel">
+14 -11
View File
@@ -16,17 +16,20 @@ const ghostMaterial = new THREE.MeshStandardMaterial({
})
const SolarPanelPreview = ({ node }: { node: SolarPanelNode }) => {
const geometry = useMemo(() => buildSolarPanelGeometry(node), [
node.rows,
node.columns,
node.panelWidth,
node.panelHeight,
node.gapX,
node.gapY,
node.frameThickness,
node.frameDepth,
node.standoffHeight,
])
const geometry = useMemo(
() => buildSolarPanelGeometry(node),
[
node.rows,
node.columns,
node.panelWidth,
node.panelHeight,
node.gapX,
node.gapY,
node.frameThickness,
node.frameDepth,
node.standoffHeight,
],
)
useEffect(() => () => geometry?.dispose(), [geometry])
+43 -22
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 { MeshStandardNodeMaterial } from 'three/webgpu'
@@ -47,11 +54,15 @@ const SolarPanelRenderer = ({ node: storeNode }: { node: SolarPanelNode }) => {
const ref = useRef<THREE.Group>(null!)
useRegistry(storeNode.id, 'solar-panel', ref)
const handlers = useNodeEvents(storeNode, 'solar-panel')
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 written by slider drags so the mesh updates in
// real time before the value is committed to the scene store.
const overrides = useLiveNodeOverrides((s) =>
s.get(storeNode.id) as Partial<SolarPanelNode> | undefined,
const overrides = useLiveNodeOverrides(
(s) => s.get(storeNode.id) as Partial<SolarPanelNode> | undefined,
)
const node = overrides ? ({ ...storeNode, ...overrides } as SolarPanelNode) : storeNode
@@ -61,29 +72,40 @@ const SolarPanelRenderer = ({ node: storeNode }: { node: SolarPanelNode }) => {
: undefined,
)
const geometry = useMemo(() => buildSolarPanelGeometry(node), [
node.rows,
node.columns,
node.panelWidth,
node.panelHeight,
node.gapX,
node.gapY,
node.frameThickness,
node.frameDepth,
node.standoffHeight,
])
const geometry = useMemo(
() => buildSolarPanelGeometry(node),
[
node.rows,
node.columns,
node.panelWidth,
node.panelHeight,
node.gapX,
node.gapY,
node.frameThickness,
node.frameDepth,
node.standoffHeight,
],
)
useEffect(() => () => geometry?.dispose(), [geometry])
// Only the structural frame/mount surface is themed (→ 'roof'). The
// panel/cell face below is intentionally dark + product-specific and is
// left untouched in both texture modes.
const frameMaterial = useMemo(() => {
if (node.material) return createMaterial(node.material)
return createMaterialFromPresetRef(node.materialPreset) ?? defaultFrameMaterial
}, [node.material, node.materialPreset])
if (!textures || (!node.material && !node.materialPreset)) {
return createSurfaceRoleMaterial('roof', 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 panelMaterial = useMemo(() => {
if (node.panelMaterial) return createMaterial(node.panelMaterial)
return createMaterialFromPresetRef(node.panelMaterialPreset) ?? getDefaultPanelMaterial()
}, [node.panelMaterial, node.panelMaterialPreset])
if (node.panelMaterial) return createMaterial(node.panelMaterial, shading)
return (
createMaterialFromPresetRef(node.panelMaterialPreset, shading) ?? getDefaultPanelMaterial()
)
}, [shading, node.panelMaterial, node.panelMaterialPreset])
const surfaceQuat = useMemo(() => {
if (!segment) return new THREE.Quaternion()
@@ -100,8 +122,7 @@ const SolarPanelRenderer = ({ node: storeNode }: { node: SolarPanelNode }) => {
? node.position[1]
: getSurfaceY(node.position[0] ?? 0, node.position[2] ?? 0, segment)
const tiltRad =
node.mountingType === 'tilted' ? (node.tiltAngle * Math.PI) / 180 : 0
const tiltRad = node.mountingType === 'tilted' ? (node.tiltAngle * Math.PI) / 180 : 0
// Roof accessories are mounted under `<group name="roof-elements">`
// in the roof renderer — that group has NO transform, so the segment
+2 -6
View File
@@ -5,8 +5,8 @@ import {
emitter,
type RoofEvent,
type RoofNode,
sceneRegistry,
SolarPanelNode,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { triggerSFX } from '@pascal-app/editor'
@@ -56,11 +56,7 @@ const SolarPanelTool = () => {
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)
+20 -15
View File
@@ -1,7 +1,7 @@
'use client'
import { type SpawnNode, useLiveTransforms, useRegistry } from '@pascal-app/core'
import { useNodeEvents, useViewer } from '@pascal-app/viewer'
import { createDefaultMaterial, useNodeEvents, useViewer } from '@pascal-app/viewer'
import { useMemo, useRef } from 'react'
import { Color, type Group, Shape } from 'three'
@@ -22,19 +22,24 @@ const SpawnRenderer = ({ node }: { node: SpawnNode }) => {
const handlers = useNodeEvents(node, 'spawn')
const liveTransform = useLiveTransforms((state) => state.get(node.id))
const walkthroughMode = useViewer((state) => state.walkthroughMode)
const shading = useViewer((state) => state.shading)
useRegistry(node.id, 'spawn', ref)
const materialProps = useMemo(
() => ({
color: SPAWN_COLOR,
emissive: SPAWN_COLOR,
emissiveIntensity: 0.08,
metalness: 0.03,
roughness: 0.42,
}),
[],
)
const material = useMemo(() => {
const next = createDefaultMaterial('#22c55e', 0.42, shading) as ReturnType<
typeof createDefaultMaterial
> & {
emissive?: Color
emissiveIntensity?: number
metalness?: number
}
next.emissive?.copy(SPAWN_COLOR)
if ('emissiveIntensity' in next) next.emissiveIntensity = 0.08
if ('metalness' in next) next.metalness = 0.03
next.needsUpdate = true
return next
}, [shading])
const arrowShape = useMemo(() => {
const shape = new Shape()
@@ -54,22 +59,22 @@ const SpawnRenderer = ({ node }: { node: SpawnNode }) => {
>
<mesh position={[0, 0.09, 0]} rotation={[-Math.PI / 2, 0, 0]} {...handlers}>
<ringGeometry args={[0.34, 0.48, 48]} />
<meshStandardMaterial {...materialProps} />
<primitive attach="material" object={material} />
</mesh>
<mesh position={[0, 0.1, -0.52]} rotation={[-Math.PI / 2, 0, 0]} {...handlers}>
<shapeGeometry args={[arrowShape]} />
<meshStandardMaterial {...materialProps} />
<primitive attach="material" object={material} />
</mesh>
<mesh position={[0, 0.41, 0]} {...handlers}>
<boxGeometry args={[0.3, 0.54, 0.16]} />
<meshStandardMaterial {...materialProps} />
<primitive attach="material" object={material} />
</mesh>
<mesh position={[0, 0.83, 0]} {...handlers}>
<boxGeometry args={[0.18, 0.18, 0.18]} />
<meshStandardMaterial {...materialProps} />
<primitive attach="material" object={material} />
</mesh>
</group>
)
@@ -11,6 +11,7 @@ export const stairSegmentDefinition: NodeDefinition<typeof StairSegmentNode> = {
schemaVersion: 1,
schema: StairSegmentNode,
category: 'structure',
surfaceRole: 'joinery',
defaults: () => {
const stub = StairSegmentNodeSchema.parse({
@@ -7,7 +7,7 @@ import {
useRegistry,
useScene,
} from '@pascal-app/core'
import { getStraightStairSegmentBodyMaterials, useNodeEvents } from '@pascal-app/viewer'
import { getStraightStairSegmentBodyMaterials, useNodeEvents, useViewer } from '@pascal-app/viewer'
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
@@ -22,13 +22,19 @@ export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => {
}, [node.id])
const handlers = useNodeEvents(node, 'stair-segment')
const shading = useViewer((s) => s.shading)
const textures = useViewer((s) => s.textures)
const colorPreset = useViewer((s) => s.colorPreset)
const parentNode = node.parentId
? (nodes[node.parentId as AnyNodeId] as StairNode | undefined)
: undefined
const material = useMemo(() => {
return getStraightStairSegmentBodyMaterials(node, parentNode)
return getStraightStairSegmentBodyMaterials(node, parentNode, shading, textures, colorPreset)
}, [
shading,
textures,
colorPreset,
node.materialPreset,
node.material,
node.material?.preset,
+1
View File
@@ -13,6 +13,7 @@ export const stairDefinition: NodeDefinition<typeof StairNode> = {
schemaVersion: 1,
schema: StairNode,
category: 'structure',
surfaceRole: 'joinery',
defaults: () => {
const stub = StairNodeSchema.parse({ id: 'stair_default' as never, type: 'stair' })
+20 -5
View File
@@ -10,12 +10,14 @@ import {
import {
createMaterial,
createMaterialFromPresetRef,
createSurfaceRoleMaterial,
DEFAULT_STAIR_MATERIAL,
getStairBodyMaterials,
getStairRailingMaterial,
NodeRenderer,
type StairBodyMaterials,
useNodeEvents,
useViewer,
} from '@pascal-app/viewer'
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
@@ -61,24 +63,37 @@ export const StairRenderer = ({ node }: { node: StairNode }) => {
}, [node.id])
const handlers = useNodeEvents(node, 'stair')
const shading = useViewer((s) => s.shading)
const textures = useViewer((s) => s.textures)
const colorPreset = useViewer((s) => s.colorPreset)
const material = useMemo(() => {
const presetMaterial = createMaterialFromPresetRef(node.materialPreset)
if (!textures) return createSurfaceRoleMaterial('joinery', colorPreset)
const presetMaterial = createMaterialFromPresetRef(node.materialPreset, shading)
if (presetMaterial) return presetMaterial
const mat = node.material
if (!mat) return DEFAULT_STAIR_MATERIAL
return createMaterial(mat)
if (!mat) return DEFAULT_STAIR_MATERIAL(shading)
return createMaterial(mat, shading)
}, [
shading,
node.materialPreset,
node.material,
node.material?.preset,
node.material?.properties,
node.material?.texture,
textures,
colorPreset,
])
const straightBodyMaterials = useMemo(() => getStairBodyMaterials(node), [node])
const straightBodyMaterials = useMemo(
() => getStairBodyMaterials(node, shading, textures, colorPreset),
[node, shading, textures, colorPreset],
)
const railingMaterial = useMemo(() => getStairRailingMaterial(node), [node])
const railingMaterial = useMemo(
() => getStairRailingMaterial(node, shading, textures, colorPreset),
[node, shading, textures, colorPreset],
)
const straightPlaceholderGeometry = useMemo(() => {
const geometry = new THREE.BufferGeometry()
+1
View File
@@ -24,6 +24,7 @@ export const wallDefinition: NodeDefinition<typeof WallNode> = {
schemaVersion: 1,
schema: WallNode,
category: 'structure',
surfaceRole: 'wall',
defaults: () => ({
object: 'node',
+6 -2
View File
@@ -1,7 +1,7 @@
'use client'
import { useRegistry, useScene, type WallNode } from '@pascal-app/core'
import { getVisibleWallMaterials, NodeRenderer, useNodeEvents } from '@pascal-app/viewer'
import { getVisibleWallMaterials, NodeRenderer, useNodeEvents, useViewer } from '@pascal-app/viewer'
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import { BufferGeometry, Float32BufferAttribute, type Mesh } from 'three'
@@ -56,7 +56,11 @@ const WallRenderer = ({ node }: { node: WallNode }) => {
}, [collisionPlaceholderGeometry, placeholderGeometry])
const handlers = useNodeEvents(node, 'wall')
const material = getVisibleWallMaterials(node)
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 material = getVisibleWallMaterials(node, shading, textures, colorPreset, sceneTheme)
return (
<mesh
+4 -4
View File
@@ -23,7 +23,7 @@ import {
triggerSFX,
type WallPlanPoint,
} 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'
@@ -381,7 +381,7 @@ function getCurrentLevelWalls(): WallNode[] {
export const WallTool: 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 wallPreviewRef = useRef<Mesh>(null!)
const startingPoint = useRef(new Vector3(0, 0, 0))
@@ -389,8 +389,8 @@ export const WallTool: 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 gridPosition: WallPlanPoint = [0, 0]
+16 -4
View File
@@ -1,7 +1,12 @@
'use client'
import { useRegistry, useScene, type WindowNode } from '@pascal-app/core'
import { createMaterial, DEFAULT_WINDOW_MATERIAL, useNodeEvents } from '@pascal-app/viewer'
import {
createMaterial,
DEFAULT_WINDOW_MATERIAL,
useNodeEvents,
useViewer,
} from '@pascal-app/viewer'
import { useLayoutEffect, useMemo, useRef } from 'react'
import type { Mesh } from 'three'
@@ -13,13 +18,20 @@ export const WindowRenderer = ({ node }: { node: WindowNode }) => {
useScene.getState().markDirty(node.id)
}, [node.id])
const handlers = useNodeEvents(node, 'window')
const shading = useViewer((s) => s.shading)
const isTransient = !!(node.metadata as Record<string, unknown> | null)?.isTransient
const material = useMemo(() => {
const mat = node.material
if (!mat) return DEFAULT_WINDOW_MATERIAL
return createMaterial(mat)
}, [node.material, node.material?.preset, node.material?.properties, node.material?.texture])
if (!mat) return DEFAULT_WINDOW_MATERIAL(shading)
return createMaterial(mat, shading)
}, [
shading,
node.material,
node.material?.preset,
node.material?.properties,
node.material?.texture,
])
return (
<mesh