feat(roof-system): six roof-accessory kinds (chimney, dormer, skylight, solar-panel, ridge-vent, box-vent) on the registry model (#330)
* Add roof surface placement support for items Items (e.g. solar panels) can now be placed on sloped roof surfaces. The placement system computes euler rotation from the roof surface normal so items sit flush on the slope instead of going inside. - Add roofStrategy to placement-strategies with enter/move/click/leave - Wire roof:enter/move/click/leave events in the placement coordinator - Add calculateRoofRotation in placement-math using surface normals - Support full 3D cursor rotation for sloped surfaces - Items on roofs are parented to the level with world-space rotation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fixed conflict * feat(box-vent): port to packages/nodes registry shape Move box-vent from the legacy scattered layout (core schema + viewer/systems/renderers + editor/tools/panels/sidebar) into a single `packages/nodes/src/box-vent/` folder following the Phase 5 Stage E pattern. The kind now self-registers via the built-in plugin. - schema lives in `core/schema/nodes/box-vent.ts` (referenced by the hand-maintained AnyNode union) and re-exports from the kind folder. - `def.renderer` reads the parent roof-segment from useScene, applies the slope tilt + segment yaw + node rotation stack, and follows the segment's useLiveTransforms override during a parent drag. - geometry builder is pure and shared by renderer / preview / tool / unit tests. `computeBoxVentSlopeTilt` is lifted as a helper for future reuse by other roof-mounted kinds (skylight / solar-panel). - placement tool listens to `roof:*` events, snaps to the segment under the cursor, creates a new BoxVentNode parented to that segment. - BoxVentEvent + `NodeEvents<'box-vent', BoxVentEvent>` added to the event bus so `useNodeEvents(node, 'box-vent')` type-checks. Verified: workspace `bun run build` + `bun run check-types` pass; 13 new unit tests in `__tests__/{schema,geometry}.test.ts` pass. Worked example for porting the remaining roof-system kinds (ridge-vent, chimney, solar-panel, skylight, dormer) — see `.claude/PORT-CHEATSHEET.md`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(ridge-vent): port to packages/nodes registry shape Same pattern as box-vent (`752ace83`): one folder under `packages/nodes/src/ridge-vent/`, schema in core, registration via the built-in plugin. No outside-the-folder edits beyond core schema/types, the event bus, and the plugin index. - pure geometry builder shared by renderer / preview / tool / tests, covering all three styles (curved cap / shingled / metal) and the optional end caps. - custom `def.renderer` reads the parent roof-segment, follows useLiveTransforms during a parent drag. No slope tilt — the ridge IS the high line of the segment so the transform stack is one level shallower than box-vent. - placement tool snaps the cursor to the ridge (segment-local Z=0) wherever the cursor lands on a segment, then commits on click with Z=0 baked into the new node's position. - RidgeVentEvent + NodeEvents<'ridge-vent', ...> added to the event bus. Verified: workspace build green, 9 new tests pass alongside the 13 box-vent tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(chimney): port to packages/nodes registry shape (Option C) Same shape as box-vent (`752ace83`) and ridge-vent (`10d489d6`). **Scope — Option C.** Chimney lands in the registry with solid geometry; the CSG-driven decoration (cap flue holes, body cavity, panels, bands, and the roof-trim that hides the chimney bottom inside the deck) is preserved in the schema but NOT rendered yet. These re-light when roof-segment migrates to Stage B and introduces a `roofCutout` capability the parent segment can read. Visual consequence: a placed chimney intersects the roof at the deck line instead of having a clean CSG-cut hole around it. Placement, move (via the legacy floating-vent-actions until the affordance tool is ported), paint, inspector edits, undo, and delete all work correctly. - pure builder returns `{ body, cap, flues, cricket }` so each piece carries its own material (body/top split matches the schema's `material` vs `topMaterial`). Body height derived from the parent segment's `wallHeight + (flat ? 0 : roofHeight) + heightAboveRidge`. - custom `def.renderer` reads the parent segment via `useScene`, follows `useLiveTransforms` during a parent drag. - placement tool listens to `roof:*` events, creates a new ChimneyNode parented to the targeted segment with segment-local coordinates. - ChimneyEvent + NodeEvents<'chimney', ChimneyEvent> added to the event bus. - ChimneyMaterialRole helper re-exported from core (used by the paint-mode picker — keeps the legacy multi-surface signature). Verified: workspace build green, 11 new tests pass (36 total across box-vent / ridge-vent / chimney). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(solar-panel): port to packages/nodes registry shape (Option C) Fourth roof-mounted kind, same shape as box-vent (`752ace83`), ridge-vent (`10d489d6`), and chimney (`45038713`). - pure builder generates the rows × columns cell grid as a single merged BufferGeometry with two render groups (frame + glass) so one mesh can take a `[frameMaterial, panelMaterial]` array. - analytical roof-surface helpers (`getSurfaceY`, `getAnalyticalNormal`, `surfaceQuatFromNormal`) live alongside the geometry builder and drive both the renderer (when `surfaceNormal` is absent from the node) and the placement preview/commit. - placement tool stores the analytical surfaceNormal on the new node so the runtime renderer and the placement preview produce the same orientation. - `solar-panel-presets.ts` moved into core (it was already imported from the schema there) and re-exported through `@pascal-app/core`. - inspector parametrics cover preset, grid, panel dims, mounting (flush/tilted with `tiltAngle` shown only when tilted), standoff, and frame. - SolarPanelEvent + NodeEvents<'solar-panel', ...> on the bus. **Option C still applies**: panels visually sit on the roof surface but the roof is NOT cut beneath them; the legacy renderer's useFrame-driven quaternion smoothing is replaced by a static quaternion computed once per render. Surface tracking under live parent rotation comes back when roof-segment migrates to Stage B. Verified: workspace build green, 16 new tests (52 total across the four ported kinds). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(skylight): port to packages/nodes registry shape (stub) Fifth roof-mounted kind. Schema is complete, but the geometry and animation surfaces are intentionally stubbed — this commit lands the registration so the kind is present in palette / inspector / sidebar / undo, and follow-up commits flesh out the type-specific geometry and the animation system. **Scope.** - Schema: every field from the archive ports verbatim (25 fields, five `skylightType` variants, opening/sliding state, lantern proportions, curb). - Geometry: frame + glass rendered as plain boxes regardless of `skylightType`. Lantern slope, opening swing tilt, and sliding panel offset from the archive are not yet rebuilt. - Animation: `operationState` and `slideFraction` round-trip via the inspector but don't drive geometry yet and don't interpolate over time. The legacy animation lived in `useInteractive.skylight Animations`, which doesn't exist on main — re-introducing that surface is a focused follow-up. - Inherits Option C from chimney: no CSG cutout into the roof; no frame CSG (4 box rails instead). **Why ship the stub now**: the framework wiring (schema in core, event bus entry, plugin registration, inspector descriptor, custom renderer with parent-segment lookup, placement tool) is the part that's reusable across all five `skylightType` variants. Wiring + box geometry takes the kind from "doesn't exist" to "place / move / paint / delete / undo all work" without committing to the harder type-specific geometry decisions. Follow-up commits: - type-specific geometry (lantern slope, opening tilt, sliding offset) - animation system + `useInteractive.skylightAnimations` extension Verified: workspace build green, 7 new tests (59 total across the five ported kinds). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(dormer): port to packages/nodes registry shape (stub) Sixth and final roof-mounted kind. Schema complete; geometry stubbed as a house silhouette (box body + triangular gable). Same Option C inheritance as chimney, solar-panel, and skylight. **Key call: window is inlined, not a hosted child.** The archive's dormer carries its window opening as parametric fields on its own schema (`windowWidth`, `windowColumns`, `windowSill`, etc.) — not a hosted `WindowNode` child. So `relations.hosts` stays unset and the kind doesn't need a `children` field. The 17 window-* fields stay in the schema; geometry beyond the silhouette stub picks them up later. - per-surface material resolution (`getEffectiveDormerSurfaceMaterial`) ports verbatim into core with the same cross-fallback semantics (top → material, side ↔ wall, then legacy `material`). - placement tool follows the established pattern (`roof:*` events, segment-local commit, analytical surfaceNormal stored). - `RoofType` import resolved from the existing `roof-segment` schema on main (the archive's `./roof-type` file is consolidated there). - DormerEvent + NodeEvents<'dormer', DormerEvent> on the bus. **Stub scope.** Geometry renders gable-only regardless of `roofType`; no window opening cutout, no window frame, no sill, no roof trim where the dormer meets the host segment. The archive's geometry relies on `getDormerExposedFaces` + `generateDormerGeometry` from the legacy roof-system, neither of which exists in `packages/nodes`. Follow-up commits add per-roofType dormer roofs, the window opening+frame+sill, and the trim/CSG against the parent segment. Verified: workspace build green, 12 new tests pass (71 total across all six ported kinds; pre-existing spawn parity failures unrelated). All six roof-system kinds now live in the registry shape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: porting cheat-sheet for the roof-system kind migrations Reference doc kept alongside the six kind ports (box-vent, ridge-vent, chimney, solar-panel, skylight, dormer) so future kind authors can follow the same shape. Captures: - the per-kind folder layout (13 files, what each one owns) - the three-checkbox composition model (`geometry` / `renderer` / `system`) - every `NodeDefinition` field with usage notes - the wiring touch-points outside the kind folder (`packages/nodes/src/index.ts`, `packages/core/src/events/bus.ts`, the AnyNode union, the core schema exports) - per-kind decisions for the six roof-system kinds (which checkboxes each one ticks, what gets stubbed, what's deferred) - pitfalls hit while porting (material-cache leaks, group-transform mutation, host-kind children fields, Path 1 vs Path 2 floorplan move) - a pre-PR checklist Kept under `.claude/` (not `wiki/`) since it's a working note for the in-flight migration, not authoritative project documentation. Move into `wiki/architecture/` later if it earns its keep. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: wire paint targets + skylight animation surface for new kinds Two small additions cherry-picked from roof-system-archive that the six kind ports depend on. Both are mechanical and unblock follow-up work without changing existing behavior. **Paint targets.** Add `chimney`, `skylight`, `dormer` to `MaterialTarget` enum so the paint picker surfaces these kinds. Wire `chimney` and `dormer` into the relevant material-library target arrays (WALL_TARGETS, SLAB_TARGETS, WALL_AND_SLAB_TARGETS, ROOF_TARGETS) so wall / slab / roof material catalog entries are offered when painting a chimney or dormer. Without this the new kinds' `material` / `materialPreset` fields can be set programmatically but the user-facing paint flow has nothing to target. **Skylight animation surface.** Port `SkylightInteractiveState` + `SkylightAnimationState` types, `skylights` / `skylightAnimations` store fields, and four actions (`setSkylightOpenState`, `removeSkylightOpenState`, `startSkylightAnimation`, `cancelSkylightAnimation`) onto `useInteractive`. Mirrors the existing door / window animation surfaces one-for-one. This is the prerequisite the skylight stub commit (`6dcee1ee`) called out — the follow-up commit that adds the skylight animation system component + wires `operationState` into the renderer's geometry now has something to consume. Neither change touches the six kind folders or their definitions — the kinds will pick up the new paint targets automatically and the skylight animation surface is dormant until a consumer ports forward. Verified: workspace build green, 71/71 kind tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(palette): surface the six roof accessories in StructureTools Closes the UX gap from the kind ports: box-vent / ridge-vent / chimney / solar-panel / skylight / dormer are registered in the registry with `def.tool` and `presentation`, but the top palette (`StructureTools`) is currently driven by a hand-coded `tools` array, not by the registry. So the new kinds existed in the codebase but had no entry point in the running editor — the user had no way to add them. - Extend `StructureTool` union in `use-editor.tsx` with the six new kind IDs so `setTool('chimney')` typechecks. - Add six entries to the `tools` array in `structure-tools.tsx`. All use the existing `/icons/roof.png` (a kind-specific icon set is a follow-up). The ToolManager already dispatches `nodeRegistry.get(tool)?.tool` (`tool-manager.tsx:28`), so clicking a new palette button activates the kind's registered `def.tool` automatically — no further wiring needed. Follow-up: a `parametrics.customPanel` on `roofDefinition` that surfaces inline "Add Chimney / Skylight / Dormer / ..." buttons in the roof inspector (matching the legacy `roof-panel.tsx` UX). For now, top palette is the entry point. Verified: workspace build green, 71/71 kind tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(roof): add inline "Add Element" section to the roof inspector When a roof is selected, the inspector now shows six quick-add buttons (Chimney, Dormer, Skylight, Solar Panel, Box Vent, Ridge Vent) in an "Add element" section between Position and Actions. Closes the discoverability gap from the kind ports — the user no longer has to hunt for the kind in the top palette. - Lives in `packages/nodes/src/roof/panel.tsx` (the roof's existing customPanel — it already escapes the auto-derived inspector to render Segments + Position + Actions). - Each button calls `useEditor.getState().setTool(kind)` to activate the kind's registered `def.tool`. The ToolManager dispatches via `nodeRegistry.get(tool)?.tool` (`tool-manager.tsx:28`), so this reuses the same code path as clicking the kind in the top palette. - Tools listen for `roof:*` events — after clicking "Add Chimney" the user clicks anywhere on a roof segment to commit the new node parented to that segment. Mirrors the legacy `roof-panel.tsx` UX (which had inline Add buttons that created hidden nodes + entered move mode); the registry-shaped equivalent activates the placement tool instead so the user sees a preview that follows the cursor. Verified: workspace build green, 71/71 kind tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ux(palette): remove roof accessories from the top palette Six kinds (box-vent / ridge-vent / chimney / solar-panel / skylight / dormer) only make sense in context of a selected roof segment — putting them in the top palette clutters it for users not actively editing a roof. They're entered through the roof inspector's "Add element" section instead (added in 275af8f4), which routes to the same registry-driven placement tools. - Remove the six entries from the `tools` array in `structure-tools.tsx`. - Keep `StructureTool` union additions in `use-editor.tsx` since `setTool('chimney')` etc. still need to typecheck from the roof panel's `activateTool` callback. Verified: workspace build green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(placement): always resolve a roof segment on click so add commits Likely root cause of "Add Element clicks not adding anything." The six roof-mounted placement tools each had a private `resolveSegmentFromWorldPoint` that returned null when the click's segment-local (x, z) fell outside `width/2 × depth/2` — but the visible merged-roof mesh extends past those bounds by the segment's overhang. Clicks landing anywhere in the eave band, or beyond every segment's nominal footprint, silently no-op'd: `onClick` early-returned on `if (!hit) return` and no node was created. - Extract a shared `resolveRoofSegmentHit` into `packages/nodes/src/roof/segment-hit.ts`. - Bounds check now includes `seg.overhang` on each side, matching the visible roof mesh. - If no segment passes the exact check, fall back to the FIRST segment with the click point projected into its local frame. Same policy the legacy `roof-panel.tsx` used (it parented all add operations to `segments[0]` and let the user move afterward). - Rewire box-vent, ridge-vent, chimney, solar-panel, skylight, and dormer placement tools to use the shared helper. Drop the per-tool copies (and the now-unused `RoofSegmentNode` import in 5 of them). After this, clicking "Add Chimney" / etc. in the roof inspector followed by a click anywhere on the visible roof commits the new node every time. Verified: workspace build green, 71/71 kind tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(roof): mount accessory children of roof-segments so Add actually adds Closes "Add Element click adds nothing to the scene." After the click, the new chimney/skylight/dormer/box-vent/ridge-vent/solar-panel node was being created in `useScene.nodes` with `parentId: <segmentId>` — but nothing mounted it visually. Two missing pieces: 1. RoofSegmentNode had no `children` array. `createNodesAction` appends `newNode.id` to `parent.children` only when the parent declares the field (`node-actions.ts:355`). Without it the parent-side write was a no-op, so the accessory existed in the store but nothing ever fired its `<NodeRenderer>` mount. 2. Even with the schema field, `roof-segment/renderer.tsx` was a leaf `<mesh>` — no recursive `<NodeRenderer>` mount of `node.children`. Fix: - `core/src/schema/nodes/roof-segment.ts`: add `children: z.array(z.string()).default([])`. - `nodes/src/roof-segment/renderer.tsx`: emit a `<group>` alongside the placeholder mesh that iterates `node.children` and mounts each via `<NodeRenderer>`. The group carries the same transform as the mesh so accessories inherit the segment's local frame — matching the segment-local coordinates each accessory renderer assumes. - `nodes/src/roof/renderer.tsx`: drop the `visible={false}` segments wrapper. `RoofSystem` only fills the parent roof's `merged-roof` mesh (`viewer/systems/roof/roof-system.tsx:172` via `getObjectByName('merged-roof')`), so segment placeholder meshes stay empty and don't z-fight with the visible roof. Mounting segments inside a visible wrapper is what lets accessory grand- children render at all. Also unblocks the user's `roof/panel.tsx` accessory-list selectors (which loop `seg.children` for chimneys/dormers/skylights/etc.) by giving the schema the field they expect. Verified: workspace build green, 71/71 kind tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(roof-accessories): drop double-applied segment transform from renderers After the previous fix (segments host accessories via recursive NodeRenderer), each accessory was being positioned at *twice* the segment offset — the renderer's outer group still applied `segment.position` and `segment.rotation`, and the React parent (the segment's group) was already at that transform too. Result: chimneys, skylights, dormers, etc. landed in the scene graph but rendered far off-screen — invisible from any normal camera view. Fix the six accessory renderers (box-vent, ridge-vent, chimney, solar-panel, skylight, dormer) to assume the segment's transform is inherited from the React tree: - Drop the outer `<group position={segmentPosition} rotation-y={...}>` wrapper. - Apply `node.position` (segment-local) directly to the ref'd outer group, with the kind-specific tilt / quaternion / yaw on inner groups. - Drop `useLiveTransforms` lookup for the segment — React tree re-renders propagate parent transform changes automatically. - Keep the `useScene` segment lookup; it's still needed for kind- specific math (slope tilt, analytical surface normal, base Y from wallHeight) that reads segment fields beyond just the transform. Chimney's outer group sits at `[0, 0, 0]` because `applyNodeTransform` in `geometry.ts` already bakes `node.position` and `node.rotation` into the chimney's vertex positions (which also bake `baseY` from `segment.wallHeight`). No double application there either. After this, Add Element → click → place puts the accessory at the clicked spot on the roof, visible and selectable. Verified: workspace build green, 71/71 kind tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(scene): migrate existing roof-segments to add `children: []` Closes "Add Element click adds to side panel but not to scene graph." The previous fix added `children: z.array(z.string()).default([])` to RoofSegmentNode, but that default only applies when zod parses the segment fresh. Every roof-segment already in a loaded scene (saved before the schema change) carries no `children` field at runtime. `createNodesAction` (`node-actions.ts:355`) appends the new child id to `parent.children` only when `'children' in parent && Array.isArray(...)` is true. For un-migrated segments that check fails — the chimney / skylight / dormer / etc. is added to `useScene.nodes` (so it shows up in the sidebar tree) but the parent-side write is a no-op, so the segment's children array remains undefined, the segment renderer's recursive `<NodeRenderer>` finds nothing to mount, and nothing appears in the 3D scene. Mirror the existing shelf migration (`use-scene.ts:351`): when the scene loads, patch every roof-segment whose `children` isn't an array to `children: []`. Existing scenes get the field on next load; new segments get it from the schema default. After this the "Add Element" click commits visibly. Verified: workspace build green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(chimney): port archive's bespoke tabbed panel as customPanel The auto-derived inspector worked but couldn't reproduce the archive's tabbed UI (Cap / Flues / Shoulder / Bands / Cricket / Panels) where each sub-section gets its own dedicated controls laid out as a grid of selectable cards. Users selecting a chimney got a flat parametric form instead of the dense bespoke editor. - Drop the legacy `packages/editor/src/components/ui/panels/chimney-panel.tsx` into `packages/nodes/src/chimney/panel.tsx` (the kind's customPanel slot). Rewrites: * Helper imports collapsed to one barrel from `@pascal-app/editor` (`ActionButton`, `ActionGroup`, `PanelSection`, `PanelWrapper`, `SegmentedControl`, `SliderControl`, `triggerSFX`). * `sfxEmitter.emit(...)` → `triggerSFX(...)` (same SFX, registry- safe export from `@pascal-app/editor`). * Inline a 3-line `cn` helper since editor doesn't re-export the legacy `lib/utils` one. * `ChimneyPanel` becomes `default export` so customPanel's lazy loader can pick it up. - Wire `chimneyParametrics.customPanel = () => import('./panel')` so the registry's parametric inspector defers to the bespoke component. - Keep `groups` in `chimneyParametrics` for MCP / fallback consumers (the parametric data is still authoritative). User-visible: clicking a chimney now opens the tabbed inspector with the exact category layout from the archive. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(chimney): CSG-trim the body against the roof so it carves cleanly Closes "chimney is not getting trimmed just like in roof-system." Previously the chimney mesh was rendered as solid geometry that intersected the roof shell visually at the deck line — Option C debt called out in `45038713`. Now the body is CSG-cut against the parent segment so only the portion above the shingles is visible, matching the archive's UX. - `packages/viewer/src/lib/csg-utils.ts`: port `csgEvaluator`, `csgGeometry`, `csgMaterials`, `computeGeometryBoundsTree`, `prepareBrushForCSG`, and the `Brush` / `SUBTRACTION` re-exports from `roof-system-archive`. Lives in viewer because `three-bvh-csg` + `three-mesh-bvh` are viewer-only deps. - `packages/viewer/src/index.ts`: expose the CSG primitives + the existing `getRoofSegmentBrushes` (which was already defined on main but not in the package surface). Adding `getRoofSegmentBrushes` to the export — internal already; this just opens it for kinds living in `@pascal-app/nodes`. - `packages/nodes/src/chimney/roof-trim.ts`: new helper `trimChimneyBodyAgainstRoof(body, segment, node)`. Wraps the body in a `Brush`, runs a two-pass `SUBTRACTION` (chimney - wallBrush - shinSlab), returns the trimmed `BufferGeometry`. Returns the input unchanged on any CSG failure so the chimney still renders. - `packages/nodes/src/chimney/renderer.tsx`: memoize a `trimmedBody` alongside the existing geo memo (keyed on the segment shape fields that drive the roof brushes) and pass it to the body mesh instead of `geo.body`. Disposal updated to release whichever buffer is actually live. Deferred (Option C still): bands and panels CSG. They were the same flow but operate on additional pieces; they re-light in a follow-up once the chimney's bands / panels geometry comes back online. Verified: workspace build green, chimney unit tests pass (14/14). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(skylight): port full v1 implementation (panel, 5-type 3D, CSG cut, animations) Brings the skylight node from a box-only stub up to feature parity with the roof-system branch. Mirrors the chimney port pattern established in 9fd42e33 and 65eec685. UI - packages/nodes/src/skylight/panel.tsx — bespoke tabbed inspector (type card picker + per-variant controls: lantern height/scale, opening angle/side/motor, sliding direction/track width, curb, frame, position, rotation). Wired via parametrics.customPanel. 3D - packages/nodes/src/skylight/{renderer,geometry,frame-csg}.tsx — full 5-variant geometry (flat / walk-on / lantern / opening / sliding) with frame ring CSG and type-specific glass (lantern pyramid + cylindrical frame bars; opening hinged glass with optional motor housing; sliding two-pane on tracks). - packages/nodes/src/skylight/preview.tsx — uses the real frame-csg builder so placement ghost matches the committed mesh. Placement / move - packages/nodes/src/skylight/tool.tsx — commits hit.localY so the skylight lands on the outer shingle surface, not the bare-rafter analytical Y (was sinking into the deck). - packages/nodes/src/skylight/move-tool.tsx — kind-owned drag wired via def.affordanceTools.move. Uses SkylightPreview as the ghost so drag and duplicate both show the real frame following the roof raycast. Reparents across segments and dirties old+new for CSG re-cut. CSG cutout - packages/viewer/src/systems/roof/roof-system.tsx — buildSkylightCutBrush added; the per-child loop in updateMergedRoofGeometry subtracts every skylight from shin/deck/wall in segment-local before the segment transform stacks on (matches v1). - Ported v1's getRoofOuterSurfaceFrameAtPoint helper (raycast against the actual outer-shingle module mesh) and made both the cut and the renderer read surface point + normal from it — keeps frame and cut aligned on every roof type incl. hip 4-faces, gambrel, mansard, dutch. - mergeVertices on the cut box before computeBoundsTree — without it three-bvh-csg silently no-ops on the BoxGeometry after applyQuaternion tilts the cut ~90° about the surface normal (hip short faces). - Renderer wraps content in an outer <group position={segment.position} rotation-y={segment.rotation}> so the frame inherits the same segment transform that applyTransform bakes into the cut brush (skylight is rendered under <group name="roof-elements"> at the roof level, not under the segment, so the renderer has to apply it explicitly). - Skylight dirty propagation in RoofSystem: edits/moves dirty the host segment so the parent roof rebuilds. - packages/viewer/src/index.ts — exposes getRoofOuterSurfaceFrameAtPoint, SurfaceFrame, getRoofSegmentBrushes, csg primitives so @pascal-app/nodes can compose roof-aware cuts without a layer violation. Animations - packages/editor/src/lib/skylight-interaction.ts — verbatim port of v1 (toggleSkylightOpenState, closeSkylightOpenState, isOperableSkylightType, SKYLIGHT_TOGGLE_ANIMATION_MS = 520). - packages/editor/src/hooks/use-keyboard.ts — R toggles, T closes operable skylights, mirroring door/window. - packages/nodes/src/skylight/system.tsx — SkylightAnimationSystem ported as def.system; advances skylightAnimations and writes operationState back to useInteractive.skylights. - Dropped the per-tick markSkylightDirty in the animation system. The renderer subscribes to useInteractive directly, so the glass swings/ slides via Zustand re-renders without dirtying the scene — the cut geometry doesn't depend on operationState, so re-CSG'ing the merged roof on every animation frame was pure waste (caused visible lag). - packages/core/src/index.ts — exports SkylightInteractiveState and SkylightAnimationState (interaction lib uses them). Drag / duplicate ghost - Floating action menu's setMovingNode → MoveTool → registry affordance now resolves to the kind-owned move tool. Duplicate already worked through structuredClone + def.schema.parse + setMovingNode; the new move-tool provides the ghost both flows use. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Persist roof segment materials across refresh * feat(solar-panel): full port from archive + placement/orientation fixes Solar panel — ported from roof-system archive to registry shape: **Inspector & presets** - Custom panel (panel.tsx) with preset card grid (Residential / Residential Large / Compact / Frameless); picking a preset now writes all four dim fields (panelWidth / panelHeight / frameThickness / frameDepth) so the inspector immediately reflects the selection. - Auto-fit to roof, Flip orientation, Custom label when no preset matches. - Live preview: renderer subscribes to useLiveNodeOverrides so slider drags update the mesh before the value is committed to the Zustand store. - Registered via parametrics.customPanel (same pattern as chimney). **Texture / materials** - Procedural cell texture (createSolarPanelTexture): chamfered cell shape, dark blue gradient, finger-line and busbar detail drawn on a 256×256 canvas, tiled per cell via stretched UVs in buildSolarPanelGeometry. - getDefaultPanelMaterial singleton uses MeshStandardNodeMaterial (WebGPU- native) so the material integrates with the MRT pass without triggering "Color target has no corresponding fragment stage output / writeMask not zero" GPU validation errors on segment reparent. - defaultFrameMaterial and move-tool previewMaterial also switched to WebGPU-safe types (MeshStandardNodeMaterial / MeshBasicMaterial). **Default grid size** - Schema defaults changed from 4 rows × 5 columns → 2 rows × 3 columns. - Placement ghost and move-tool ghost use a compact 2×3 footprint; committed panels also default to 2×3. **Placement tool** - Commit position uses raycast hit Y (hit.localY from segObj.worldToLocal) instead of analytical getSurfaceY so the panel lands exactly where the ghost was shown rather than sinking into the deck/shingle layers. - Ghost orientation uses the same analytical-normal + explicit-yaw pattern as the placement tool for correctness on rotated segments. **Move tool** - Rewrote ghost to use resolveRoofSegmentHit + getAnalyticalNormal (segment-local) + explicit rotation-y group, matching the placement tool's ghost layout exactly. Dropped unreliable event.normal / world- space quat path that gave wrong tilt on any segment with rotation ≠ 0. - Committed surfaceNormal is now segment-local (not world-space) so the renderer's surfaceQuat + outer segment.rotation group compose correctly without double-rotating the panel. - Uses shared resolveRoofSegmentHit (with surface-Y disambiguation) instead of the private copy, so segment hopping respects the correct face. - Reparents children arrays on segment hop. **Renderer** - Applies segment.position + segment.rotation explicitly (roof accessories are mounted under roof-elements group which has no transform, not under segment subtree). - Merges useLiveNodeOverrides so slider drags update the 3D mesh in real time (same pattern as elevator/skylight renderers). **Scene graph** - SolarPanelTreeNode added; registered in tree-node.tsx type map so panels appear under their parent roof-segment in the sidebar. **Segment-hit disambiguation** - resolveRoofSegmentHit now scores all bbox-passing candidates by |localY − analyticalSurfaceY(localX, localZ)| and picks the smallest, fixing the long-standing bug where hip/gable segments at the same roof origin all pass the axis-aligned bbox test and the first-match (always segments[0]) was returned regardless of which slope was clicked. Benefits all roof-accessory placement tools (chimney, box-vent, skylight, dormer, solar-panel). **Hip-roof normal fix** - getAnalyticalNormal for hip now uses slopeReach = min(w,d)/2 for the Y component on all four faces. The old code used depth/2 for front/back and width/2 for sides, which was only correct for square (w==d) hips; for any other aspect ratio the long-axis faces tilted the panel at the wrong angle. **Dormer, dormer move-tool, window-frame, ridge-vent, box-vent, skylight** - Assorted in-progress work: dormer window-frame geometry, move-tool port, panel refinements, ridge-vent / box-vent panel additions, skylight CSG frame refinements, roof-system geometry improvements, material-paint support, post-processing cleanup. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * perf(chimney): fix hook order + share materials + memoise segment brushes - renderer.tsx: hoist `surfaceArray` useMemo above the `!segment || !geo` early return so hook call order stays stable across renders (the previous order would have crashed React the first time segment or geo flipped to null mid-session). - renderer.tsx: replace the module-scoped `bodyMaterial` / `topMaterial` singletons with per-instance fallback materials so a paint-mode or debug mutation on one chimney can't bleed into every other unpainted chimney on the scene; dispose them on unmount. - renderer.tsx: collapse the 36-field hand-maintained dep array on the `geo` useMemo (and the 10-field one on `trimmedBody`) down to the memoised `node` / `segment` references — adding a new schema field no longer risks stale geometry from a forgotten dep, and the `eslint-disable react-hooks/exhaustive-deps` lines are gone. - renderer.tsx + roof-trim.ts: memoise `getRoofSegmentBrushes(segment)` per-segment-shape in the renderer instead of rebuilding the four CSG-ready brushes inside `trimChimneyBodyAgainstRoof` on every call. A chimney slider drag changes `node.*` but not the segment, so the brushes now survive the entire drag instead of being rebuilt and disposed every frame. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(scene): pin reparent behavior for roof-mounted kinds under repeated A→B→A→B Investigating a reported crash where moving a vent across roof segments three times in a row crashes the scene. The hypothesis was duplicate IDs in the host segments' `children` arrays. These tests prove that's NOT the cause: at the store level, the auto-reparent inside `updateNodesAction` leaves children lists clean under repeated hopping for every roof-mounted kind (box-vent, chimney, skylight, dormer, solar-panel, ridge-vent), and even the redundant manual-then- auto pattern the vent move-tools use converges to the same correct state. Crash root cause still under investigation, but these pins prevent the obvious-and-tempting regression where someone "fixes" reparent by hand and accidentally lets duplicates through. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(chimney): give each sub-mesh its own name Body, cap, flues, cricket, and bands were all named `chimney-surface`, so hover/selection couldn't distinguish them and panel breadcrumbs couldn't say "Chimney cap" vs "Chimney body". Rename to `chimney-{body,cap,flues,cricket,bands}`. No code looked up the old literal, so this is a pure naming improvement. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(chimney): smooth round shading, radial cap UVs, cap reveal, flue pots Four visual upgrades to the chimney builder, all in `geometry.ts`: - **Round chimneys now render smooth, not faceted.** The previous `pushCylinderFaces` emitted unindexed triangles, so `computeVertexNormals()` baked per-triangle face normals into every vertex — the 24 polygon segments of a round body / cap / band were visible as flats. Round paths now build per-tier `THREE.CylinderGeometry` (indexed, side vertices shared across radial segments) and merge via `mergeGeometries`. Crisp rim edges are preserved because CylinderGeometry uses separate cap vertices. - **Radial cap UVs.** Old `pushCylinderFaces` pushed `(0,0)` for every vertex on the top/bottom fan, so any texture on a round chimney smeared to a point at the caps. CylinderGeometry gives proper radial UVs (0.5 ± 0.5·cos/sin) for free. - **Cap reveal.** The cap used to sit flush on the body, reading as glued on. New `CAP_REVEAL = 0.003` (3 mm) air gap above the body catches a shadow line and sells the cap as a separate stone / metal piece. `capTopY` (used for flue placement) updates so flues still sit on the actual cap top. - **Flue pots, not drainpipes.** Each flue was a single straight cylinder / box — visually a "drainpipe", not a chimney. New two-tier silhouette: a tall straight shaft topped by a short overhanging rim (12 % of height, capped at 4 cm; rim radius flares 12 %). Reads as a terracotta pot. Total height still equals `flueHeight`, so the bore cutter in `holes.ts` covers the whole envelope unchanged. Removed the now-unused `pushCylinderFaces` helper. Slab path unchanged — square chimneys keep their crisp 90° corners. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(chimney): cornerBevel for square bodies / caps / bands Square chimneys read as plastic boxes at any distance because every vertical edge is a perfect 90° corner that catches no light. New `cornerBevel` field (default 0 → existing scenes unchanged) replaces each corner with a 45° chamfer face. Real masonry chimneys often ship the same detail — a small bevel (~1-2 cm) breaks up the silhouette and reads as stone or chamfered brick. - Schema: add `cornerBevel: z.number().default(0)` to ChimneyNode. - Geometry: extend `pushSlabFaces` with an optional `bevel` param. When > 0, dispatch to a new `pushOctagonalSlabFaces` that emits an 8-vertex ring per y-level (axis-aligned faces + 45° chamfer faces) plus fan-triangulated octagonal caps. UVs follow the same physical-meter convention as the unchamfered path so a brick texture tiles at a consistent rate with and without bevel. - Thread `node.cornerBevel` through `buildBodyGeometry`, `buildCapGeometry`, and `buildBandsGeometry` (square paths only — round bodies have no corners to bevel). - Parametrics: expose under the Body group with `visibleIf` gating on square body for the MCP / fallback inspector. - Panel: add a "Corner Bevel" SliderControl in the Footprint section, same conditional visibility, clamped at `min(width, depth) / 2`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(chimney): style presets — Brick / Stone / Modern / Round The chimney panel exposes 30+ sliders; landing on a coherent silhouette (corbeled stone with a sloped cap and a cricket vs. a straight brick with a double band) takes a dozen edits even when you know what you want. New "Style" segmented control at the top of the panel applies a curated bundle of fields in one click. Presets only touch shape / silhouette / accessory fields: `bodyShape`, `shoulderStyle*`, `cap*`, `band*`, `cricket*`, `cornerBevel`, `panel*`, `flue*`. Dimensions (`width`, `depth`, `heightAboveRidge`), placement (`position`, `rotation`, `roofSegmentId`), and paint (`material*`, `topMaterial*`) are deliberately left alone — applying a preset to an already-sized, already-painted chimney resizes nothing and doesn't overwrite the user's material choices. - `presets.ts`: four preset bundles + `detectActiveChimneyPreset` helper for highlighting the matching preset in the segmented control. - `panel.tsx`: new "Style" PanelSection above Footprint, segmented control wired to `commitProp(chimneyPresets[key])`. Renders with no segment highlighted ("custom") when the current node doesn't match any preset exactly. - `__tests__/presets.test.ts`: round-trip each preset, confirm fresh-default chimneys are NOT detected as any preset, and confirm non-preset fields (dims / materials / placement) don't knock a chimney out of a preset. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(chimney): drop the Stone preset Leaves Brick / Modern / Round. The parameterised round-trip test auto-adjusts via `CHIMNEY_PRESET_KEYS`; no test code change needed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(dormer): bugs, perf, UX, refactor + bundled pitch WIP Dormer review-backlog batch across five passes. Plus the previously unstaged pitch/roofHeight migration on RoofSegmentNode bundled in per session continuity. Dormer — bugs / dead code: - implement the windowSill (toggle was UI-only before) - ghost preview reads wallSkirtHeight and branches on roofType=flat - live-override slider drag swaps the heavy CSG for the fallback - consolidate arch/rounded shape builders between viewer CSG and frame - drop unused surfaceNormal field - collapse getEffectiveDormerSurfaceMaterial fall-through - confine the panel's updateWorldMatrix into a single useMemo - preserve position Y on panel commits (was being zeroed) Dormer — schema hygiene: - DORMER_DEFAULTS named constants replace inline magic numbers - collapse windowCornerRadius + windowRadiusMode + windowCornerRadii into the tuple alone; "All vs Individual" is derived UI state - drop the `as never` id casts; rely on objectId default factory Dormer — tactile UX: - R / Shift+R rotates the placement ghost by ±15° - auto-number new dormer names ("Dormer N", smallest free integer) - DORMER_PLACEMENT_SNAP_M + ROTATION_STEP constants extracted Dormer — code shape: - new use-dormer-placement hook dedupes tool + move-tool (~90% shared) - new <DormerWindowAssembly> isolates the frame/glass/sill JSX - panel.tsx 788 -> 295 lines; Position / Window / Actions sections extracted into per-file components Bundled pitch WIP (pre-existing, unrelated to dormer): - RoofSegmentNode.roofHeight removed; pitch (degrees) added - new helpers in roof-segment: getActiveRoofHeight, getPitchFromActiveRoofHeight, getSegmentSlopeFrame, ROOF_SHAPE_DEFAULTS - migration in use-scene.ts converts legacy roofHeight to pitch - consumers updated: chimney, box-vent, ridge-vent, solar-panel, roof-segment, roof, segment-hit, roof-tool, mcp construction-tools Verification: 12/12 dormer tests pass; targeted tsc on dormer files clean. Workspace bun build of nodes is also affected by the pitch WIP, which is included here per request. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(roof): per-segment top/edge/wall materials + restore legacy UV flow - RoofSegmentNode gains optional topMaterial / edgeMaterial / wallMaterial fields mirroring the parent roof. getEffectiveSegmentSurfaceMaterial resolves through segment-role → segment-legacy → parent fallback. - Segment renderer builds the 4-slot array per role with the parent's array as a fallback so paint at any level reaches the right surface. - Painting a segment directly (segment edit mode hover) writes to the segment's role fields via buildRoofSegmentSurfaceMaterialPatch — the parent roof and other segments are untouched. - Segments with any material override render as their own per-segment mesh inside a new always-visible 'painted-segments' group; the merged- roof CSG skips them (hasSegmentMaterialOverride) so we don't double- paint with the roof's default array. - Paint preview now dispatches to a segment-aware path (applyRoofSegmentPaintPreview) so hover effects land on the visible per-segment mesh instead of the hidden merged-roof. - Drop the createRoofUvGeometry post-CSG re-projection. UVs now flow through CSG (csgEvaluator.attributes includes 'uv') exactly as in the legacy roof-system branch. - Drop the stray [skylight-cut] console.log left in the hot path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(roof-system): registry capabilities for roof accessories, paint, and keyboard Replace kind-name branches in framework code with registry-driven dispatch. Three new NodeDefinition slots back the migration: - capabilities.roofAccessory — host roof cascade + optional CSG cut. Lets viewer's RoofSystem iterate dirty children and call buildCut on any kind that declares it, instead of switching on node.type. Dormer + skylight cut builders moved into packages/nodes/<kind>/. - capabilities.paint — resolveRole / buildPatch / applyPreview / getEffectiveMaterial. Chimney, dormer, and wall now route through it; per-kind arms deleted from selection-manager + material-paint. - keyboardActions — R / T handlers contributed by the kind. Skylight's open/close logic moved from editor/lib to nodes/skylight/interaction. Dormer + skylight kind code (geometry, fallback shape, exposed-face math, window-dim resolver, CSG cut builders) now lives under packages/nodes/ src/<kind>/ instead of packages/viewer/src/systems/roof/roof-system.tsx. The viewer keeps only roof-generic primitives (roof-segment brushes, surface-frame query, CSG dummy mats, material-slot remap). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * review: remove PORT-CHEATSHEET, document roofAccessory/paint/keyboardActions, fix double registry lookup - Remove .claude/PORT-CHEATSHEET.md (AI authoring aid, not for public repo) - Document three new NodeDefinition capabilities in wiki/architecture/node-definitions.md: roofAccessory, paint, keyboardActions - Fix double nodeRegistry.get() lookup in use-keyboard.ts: replace !.keyboardActions!.r!.run() with ?.keyboardActions?.r?.run() for both R and T arms * fix(item-placement): memoize preview/dimension callbacks to stop placement loop updatePreviewGeometry and updateDimensionGuides were declared as plain functions in the component body, so they got a fresh identity every render. Both sit in the placement setup effect's dependency array, which made React tear the effect down and re-run it on every commit — its teardown deletes the draft node while the setup re-creates it, producing an infinite delete/recreate loop ("Maximum update depth exceeded") when opening furnish mode. Wrap both in useCallback with empty deps (they only close over stable refs, module-level helpers, and the setDimensionBounds setter). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(item-placement): correct preview box dimensions and floor rotation Two issues surfaced after the placement-loop memoization removed the accidental every-render recompute that was masking them: 1. Preview box used stale (asset-default) dimensions at draft creation because nothing recomputed it once the imperative draft was made. Recompute the box from the freshly-created draft in `ensureDraft` and the chained next-draft path in `onGridClick`. 2. The green/red box (and the live transform the 2D floorplan mirrors) ignored the floor item's rotation: - `floorStrategy.move` returned a hardcoded `cursorRotationY: 0`; now returns the draft's rotation (`rotY`). - `onGridMove` never applied `result.cursorRotationY` to the cursor group; now it does, so box + floorplan track the draft on every move. - the init seed used the mesh world quaternion, which double-counts building rotation for floor items; floor now seeds from the node's local Y rotation (wall/ceiling keep the world-quaternion path). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: open-pascal <open@pascal.app> Co-authored-by: Wassim SAMAD <wass08@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
open-pascal
Wassim SAMAD
parent
3cb318e445
commit
87384cfbab
@@ -2,7 +2,6 @@ import { Agentation } from 'agentation'
|
|||||||
import { GeistPixelSquare } from 'geist/font/pixel'
|
import { GeistPixelSquare } from 'geist/font/pixel'
|
||||||
import { Barlow } from 'next/font/google'
|
import { Barlow } from 'next/font/google'
|
||||||
import localFont from 'next/font/local'
|
import localFont from 'next/font/local'
|
||||||
import Script from 'next/script'
|
|
||||||
import { ClientBootstrap } from './client-bootstrap'
|
import { ClientBootstrap } from './client-bootstrap'
|
||||||
import './globals.css'
|
import './globals.css'
|
||||||
|
|
||||||
@@ -34,10 +33,10 @@ export default function RootLayout({
|
|||||||
>
|
>
|
||||||
<head>
|
<head>
|
||||||
{process.env.NODE_ENV === 'development' && (
|
{process.env.NODE_ENV === 'development' && (
|
||||||
<Script
|
<script
|
||||||
|
async
|
||||||
crossOrigin="anonymous"
|
crossOrigin="anonymous"
|
||||||
src="//unpkg.com/react-scan/dist/auto.global.js"
|
src="//unpkg.com/react-scan/dist/auto.global.js"
|
||||||
strategy="beforeInteractive"
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ function loadBuiltinsSync(): void {
|
|||||||
if (builtinsLoaded) return
|
if (builtinsLoaded) return
|
||||||
builtinsLoaded = true
|
builtinsLoaded = true
|
||||||
for (const def of builtinPlugin.nodes ?? []) {
|
for (const def of builtinPlugin.nodes ?? []) {
|
||||||
|
// Skip kinds the registry already has. The module-closure flag
|
||||||
|
// above resets on HMR, but the registry singleton (in @pascal-app/core)
|
||||||
|
// persists — without this guard we'd throw on the first duplicate.
|
||||||
|
if (nodeRegistry.has((def as AnyNodeDefinition).kind)) continue
|
||||||
registerNode(def as AnyNodeDefinition)
|
registerNode(def as AnyNodeDefinition)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ import type { ThreeEvent } from '@react-three/fiber'
|
|||||||
import mitt from 'mitt'
|
import mitt from 'mitt'
|
||||||
import type { Object3D } from 'three'
|
import type { Object3D } from 'three'
|
||||||
import type {
|
import type {
|
||||||
|
BoxVentNode,
|
||||||
BuildingNode,
|
BuildingNode,
|
||||||
CeilingNode,
|
CeilingNode,
|
||||||
|
ChimneyNode,
|
||||||
|
DormerNode,
|
||||||
ColumnNode,
|
ColumnNode,
|
||||||
DoorNode,
|
DoorNode,
|
||||||
ElevatorNode,
|
ElevatorNode,
|
||||||
@@ -11,11 +14,14 @@ import type {
|
|||||||
GuideNode,
|
GuideNode,
|
||||||
ItemNode,
|
ItemNode,
|
||||||
LevelNode,
|
LevelNode,
|
||||||
|
RidgeVentNode,
|
||||||
RoofNode,
|
RoofNode,
|
||||||
RoofSegmentNode,
|
RoofSegmentNode,
|
||||||
ScanNode,
|
ScanNode,
|
||||||
ShelfNode,
|
ShelfNode,
|
||||||
SiteNode,
|
SiteNode,
|
||||||
|
SkylightNode,
|
||||||
|
SolarPanelNode,
|
||||||
SlabNode,
|
SlabNode,
|
||||||
SpawnNode,
|
SpawnNode,
|
||||||
StairNode,
|
StairNode,
|
||||||
@@ -80,6 +86,12 @@ export type DoorEvent = NodeEvent<DoorNode>
|
|||||||
export type ElevatorEvent = NodeEvent<ElevatorNode>
|
export type ElevatorEvent = NodeEvent<ElevatorNode>
|
||||||
export type ScanEvent = NodeEvent<ScanNode>
|
export type ScanEvent = NodeEvent<ScanNode>
|
||||||
export type GuideEvent = NodeEvent<GuideNode>
|
export type GuideEvent = NodeEvent<GuideNode>
|
||||||
|
export type BoxVentEvent = NodeEvent<BoxVentNode>
|
||||||
|
export type RidgeVentEvent = NodeEvent<RidgeVentNode>
|
||||||
|
export type ChimneyEvent = NodeEvent<ChimneyNode>
|
||||||
|
export type SolarPanelEvent = NodeEvent<SolarPanelNode>
|
||||||
|
export type SkylightEvent = NodeEvent<SkylightNode>
|
||||||
|
export type DormerEvent = NodeEvent<DormerNode>
|
||||||
|
|
||||||
// Event suffixes - exported for use in hooks
|
// Event suffixes - exported for use in hooks
|
||||||
export const eventSuffixes = [
|
export const eventSuffixes = [
|
||||||
@@ -213,6 +225,12 @@ type EditorEvents = GridEvents &
|
|||||||
NodeEvents<'door', DoorEvent> &
|
NodeEvents<'door', DoorEvent> &
|
||||||
NodeEvents<'scan', ScanEvent> &
|
NodeEvents<'scan', ScanEvent> &
|
||||||
NodeEvents<'guide', GuideEvent> &
|
NodeEvents<'guide', GuideEvent> &
|
||||||
|
NodeEvents<'box-vent', BoxVentEvent> &
|
||||||
|
NodeEvents<'ridge-vent', RidgeVentEvent> &
|
||||||
|
NodeEvents<'chimney', ChimneyEvent> &
|
||||||
|
NodeEvents<'solar-panel', SolarPanelEvent> &
|
||||||
|
NodeEvents<'skylight', SkylightEvent> &
|
||||||
|
NodeEvents<'dormer', DormerEvent> &
|
||||||
CameraControlEvents &
|
CameraControlEvents &
|
||||||
ToolEvents &
|
ToolEvents &
|
||||||
GuideEvents &
|
GuideEvents &
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
export type {
|
export type {
|
||||||
|
BoxVentEvent,
|
||||||
BuildingEvent,
|
BuildingEvent,
|
||||||
CameraControlEvent,
|
CameraControlEvent,
|
||||||
CameraControlFitSceneEvent,
|
CameraControlFitSceneEvent,
|
||||||
CeilingEvent,
|
CeilingEvent,
|
||||||
|
ChimneyEvent,
|
||||||
ColumnEvent,
|
ColumnEvent,
|
||||||
DoorEvent,
|
DoorEvent,
|
||||||
|
DormerEvent,
|
||||||
ElevatorEvent,
|
ElevatorEvent,
|
||||||
EventSuffix,
|
EventSuffix,
|
||||||
FenceEvent,
|
FenceEvent,
|
||||||
@@ -13,12 +16,15 @@ export type {
|
|||||||
ItemEvent,
|
ItemEvent,
|
||||||
LevelEvent,
|
LevelEvent,
|
||||||
NodeEvent,
|
NodeEvent,
|
||||||
|
RidgeVentEvent,
|
||||||
RoofEvent,
|
RoofEvent,
|
||||||
RoofSegmentEvent,
|
RoofSegmentEvent,
|
||||||
ScanEvent,
|
ScanEvent,
|
||||||
ShelfEvent,
|
ShelfEvent,
|
||||||
SiteEvent,
|
SiteEvent,
|
||||||
|
SkylightEvent,
|
||||||
SlabEvent,
|
SlabEvent,
|
||||||
|
SolarPanelEvent,
|
||||||
SpawnEvent,
|
SpawnEvent,
|
||||||
StairEvent,
|
StairEvent,
|
||||||
StairSegmentEvent,
|
StairSegmentEvent,
|
||||||
@@ -82,6 +88,8 @@ export {
|
|||||||
type ElevatorInteractiveState,
|
type ElevatorInteractiveState,
|
||||||
type ElevatorPhase,
|
type ElevatorPhase,
|
||||||
type ItemInteractiveState,
|
type ItemInteractiveState,
|
||||||
|
type SkylightAnimationState,
|
||||||
|
type SkylightInteractiveState,
|
||||||
useInteractive,
|
useInteractive,
|
||||||
type WindowAnimationState,
|
type WindowAnimationState,
|
||||||
type WindowInteractiveState,
|
type WindowInteractiveState,
|
||||||
|
|||||||
@@ -14,13 +14,22 @@ export type MaterialCatalogItem = {
|
|||||||
preset: MaterialPresetPayload
|
preset: MaterialPresetPayload
|
||||||
}
|
}
|
||||||
|
|
||||||
const WALL_TARGETS: MaterialTarget[] = [MaterialTargetSchema.enum.wall]
|
const WALL_TARGETS: MaterialTarget[] = [
|
||||||
|
MaterialTargetSchema.enum.wall,
|
||||||
|
MaterialTargetSchema.enum.chimney,
|
||||||
|
MaterialTargetSchema.enum.dormer,
|
||||||
|
]
|
||||||
|
|
||||||
const SLAB_TARGETS: MaterialTarget[] = [MaterialTargetSchema.enum.slab]
|
const SLAB_TARGETS: MaterialTarget[] = [
|
||||||
|
MaterialTargetSchema.enum.slab,
|
||||||
|
MaterialTargetSchema.enum.chimney,
|
||||||
|
]
|
||||||
|
|
||||||
const WALL_AND_SLAB_TARGETS: MaterialTarget[] = [
|
const WALL_AND_SLAB_TARGETS: MaterialTarget[] = [
|
||||||
MaterialTargetSchema.enum.wall,
|
MaterialTargetSchema.enum.wall,
|
||||||
MaterialTargetSchema.enum.slab,
|
MaterialTargetSchema.enum.slab,
|
||||||
|
MaterialTargetSchema.enum.chimney,
|
||||||
|
MaterialTargetSchema.enum.dormer,
|
||||||
]
|
]
|
||||||
|
|
||||||
const STAIR_TARGETS: MaterialTarget[] = [
|
const STAIR_TARGETS: MaterialTarget[] = [
|
||||||
@@ -36,6 +45,8 @@ const STAIR_AND_FENCE_TARGETS: MaterialTarget[] = [
|
|||||||
const ROOF_TARGETS: MaterialTarget[] = [
|
const ROOF_TARGETS: MaterialTarget[] = [
|
||||||
MaterialTargetSchema.enum.roof,
|
MaterialTargetSchema.enum.roof,
|
||||||
MaterialTargetSchema.enum['roof-segment'],
|
MaterialTargetSchema.enum['roof-segment'],
|
||||||
|
MaterialTargetSchema.enum.chimney,
|
||||||
|
MaterialTargetSchema.enum.dormer,
|
||||||
]
|
]
|
||||||
|
|
||||||
const CEILING_TARGETS: MaterialTarget[] = [MaterialTargetSchema.enum.ceiling]
|
const CEILING_TARGETS: MaterialTarget[] = [MaterialTargetSchema.enum.ceiling]
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export type {
|
|||||||
HostableConfig,
|
HostableConfig,
|
||||||
IconRef,
|
IconRef,
|
||||||
Issue,
|
Issue,
|
||||||
|
KeyboardAction,
|
||||||
|
KeyboardActions,
|
||||||
LazyComponent,
|
LazyComponent,
|
||||||
McpOverrides,
|
McpOverrides,
|
||||||
Modifiers,
|
Modifiers,
|
||||||
@@ -46,6 +48,11 @@ export type {
|
|||||||
NodeCategory,
|
NodeCategory,
|
||||||
NodeDefinition,
|
NodeDefinition,
|
||||||
NodeRegistry,
|
NodeRegistry,
|
||||||
|
PaintCapability,
|
||||||
|
PaintEffectiveMaterialArgs,
|
||||||
|
PaintPatchArgs,
|
||||||
|
PaintPreviewArgs,
|
||||||
|
PaintResolveArgs,
|
||||||
ParametricDescriptor,
|
ParametricDescriptor,
|
||||||
ParamField,
|
ParamField,
|
||||||
ParamGroup,
|
ParamGroup,
|
||||||
@@ -53,6 +60,7 @@ export type {
|
|||||||
Presentation,
|
Presentation,
|
||||||
Relations,
|
Relations,
|
||||||
RendererSource,
|
RendererSource,
|
||||||
|
RoofAccessoryConfig,
|
||||||
RotatableConfig,
|
RotatableConfig,
|
||||||
ScalableConfig,
|
ScalableConfig,
|
||||||
SceneApi,
|
SceneApi,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ComponentType } from 'react'
|
import type { ComponentType } from 'react'
|
||||||
import type { Object3D } from 'three'
|
import type { BufferGeometry, Object3D } from 'three'
|
||||||
import type { ZodObject, z } from 'zod'
|
import type { ZodObject, z } from 'zod'
|
||||||
|
import type { MaterialSchema } from '../schema/material'
|
||||||
import type { AnyNode, AnyNodeId } from '../schema/types'
|
import type { AnyNode, AnyNodeId } from '../schema/types'
|
||||||
|
|
||||||
// ─── GeometryContext ─────────────────────────────────────────────────
|
// ─── GeometryContext ─────────────────────────────────────────────────
|
||||||
@@ -652,10 +653,51 @@ export type NodeDefinition<S extends ZodObject<any>> = {
|
|||||||
|
|
||||||
presentation?: Presentation
|
presentation?: Presentation
|
||||||
mcp?: McpOverrides
|
mcp?: McpOverrides
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional keyboard shortcut handlers contributed by the kind. The
|
||||||
|
* editor's keyboard hook looks these up by event name (`r` for R /
|
||||||
|
* Shift+R, `t` for T / Shift+T) and runs the matching handler when
|
||||||
|
* the user presses that key with a single node of this kind
|
||||||
|
* selected. The fallback rotation behaviour kicks in only when the
|
||||||
|
* action's `appliesTo` returns false.
|
||||||
|
*
|
||||||
|
* Replaces editor-side per-kind switches in `use-keyboard.ts` — a
|
||||||
|
* kind that wants to override R / T just sets this field instead of
|
||||||
|
* extending a hand-written `if/else` chain. Door / window are
|
||||||
|
* legacy direct calls today (follow-up: migrate them under this
|
||||||
|
* capability too).
|
||||||
|
*/
|
||||||
|
keyboardActions?: KeyboardActions
|
||||||
}
|
}
|
||||||
|
|
||||||
export type NodeCategory = 'site' | 'structure' | 'furnish' | 'analysis' | 'utility'
|
export type NodeCategory = 'site' | 'structure' | 'furnish' | 'analysis' | 'utility'
|
||||||
|
|
||||||
|
// ─── Keyboard actions ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type KeyboardActions = {
|
||||||
|
/** R / Shift+R primary action. */
|
||||||
|
r?: KeyboardAction
|
||||||
|
/** T / Shift+T secondary action. */
|
||||||
|
t?: KeyboardAction
|
||||||
|
}
|
||||||
|
|
||||||
|
export type KeyboardAction = {
|
||||||
|
/**
|
||||||
|
* Predicate that gates the action. Return `false` when the
|
||||||
|
* keystroke should fall through to the editor's default behaviour
|
||||||
|
* for this kind (typically rotation). Skylight uses this to short-
|
||||||
|
* circuit the action for non-operable type variants.
|
||||||
|
*/
|
||||||
|
appliesTo: (node: AnyNode) => boolean
|
||||||
|
/**
|
||||||
|
* Run the action. The editor handles `preventDefault` and the
|
||||||
|
* shared sfx — the handler should only touch scene / interactive
|
||||||
|
* state.
|
||||||
|
*/
|
||||||
|
run: (node: AnyNode) => void
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Presentation (tool palette + UI surface) ────────────────────────
|
// ─── Presentation (tool palette + UI surface) ────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -736,6 +778,131 @@ export type Capabilities = {
|
|||||||
selectable?: SelectableConfig
|
selectable?: SelectableConfig
|
||||||
interactive?: boolean
|
interactive?: boolean
|
||||||
floorPlaced?: FloorPlacedConfig
|
floorPlaced?: FloorPlacedConfig
|
||||||
|
roofAccessory?: RoofAccessoryConfig
|
||||||
|
paint?: PaintCapability
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-kind paint behaviour. Lets the editor's selection-manager
|
||||||
|
* route paint hover / click / preview through a generic dispatcher
|
||||||
|
* instead of adding an `if (node.type === '<kind>')` arm for every
|
||||||
|
* paintable kind.
|
||||||
|
*
|
||||||
|
* The capability owns the four kind-specific decisions:
|
||||||
|
* 1. Which logical surface (`role`) the click landed on.
|
||||||
|
* 2. The patch to commit on click.
|
||||||
|
* 3. How to apply a preview material to the registered mesh
|
||||||
|
* subtree for that role (which mesh, which slot).
|
||||||
|
* 4. How to read the currently-effective material for a role —
|
||||||
|
* drives the color picker's "current value" indicator.
|
||||||
|
*
|
||||||
|
* The editor still owns the visual chrome — hover/cursor styling,
|
||||||
|
* the `selectedMaterialTarget` round-trip, the paint-mode toolbar.
|
||||||
|
* Kinds with no paint behaviour omit `paint`.
|
||||||
|
*/
|
||||||
|
export type PaintCapability = {
|
||||||
|
/**
|
||||||
|
* Resolve which logical surface the user clicked. Returns `null`
|
||||||
|
* when the face shouldn't be painted (e.g. interior slot exposed
|
||||||
|
* by accident, normal too oblique for an unambiguous side).
|
||||||
|
*/
|
||||||
|
resolveRole: (args: PaintResolveArgs) => string | null
|
||||||
|
/**
|
||||||
|
* Build the node-update patch that applies the new material at
|
||||||
|
* `role`. Returned partial is merged into the node by the editor.
|
||||||
|
*/
|
||||||
|
buildPatch: (args: PaintPatchArgs) => Partial<AnyNode>
|
||||||
|
/**
|
||||||
|
* Apply a preview to the kind's registered mesh subtree at
|
||||||
|
* `role`. The kind builds whatever preview material(s) it needs
|
||||||
|
* (single material, full material array, multi-slot patch — all
|
||||||
|
* up to the kind) and swaps them in. Returns a cleanup callback
|
||||||
|
* that restores the original assignments; the editor calls it
|
||||||
|
* when the preview ends (hover changes, paint commits, paint
|
||||||
|
* cancels).
|
||||||
|
*
|
||||||
|
* Returning `null` means the kind couldn't preview at this role
|
||||||
|
* (typically because the registered mesh isn't mounted yet); the
|
||||||
|
* editor falls back to the "not-allowed" cursor.
|
||||||
|
*/
|
||||||
|
applyPreview: (args: PaintPreviewArgs) => (() => void) | null
|
||||||
|
/**
|
||||||
|
* Read the currently-effective material for `role` on `node`,
|
||||||
|
* after walking any parent-fallback chain (segment → parent roof,
|
||||||
|
* etc.). Powers `resolveActivePaintMaterialFromSelection` — when
|
||||||
|
* the user has a paint target selected, the editor uses this to
|
||||||
|
* show the role's current value in the picker.
|
||||||
|
*
|
||||||
|
* Returns `null` when the role doesn't apply to this kind.
|
||||||
|
*/
|
||||||
|
getEffectiveMaterial?: (args: PaintEffectiveMaterialArgs) => {
|
||||||
|
material: MaterialSchema | undefined
|
||||||
|
materialPreset: string | undefined
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PaintResolveArgs = {
|
||||||
|
node: AnyNode
|
||||||
|
/**
|
||||||
|
* The geometry's material-slot index resolved from the pointer
|
||||||
|
* hit (via three.js groups). `null` when no group covers the
|
||||||
|
* face.
|
||||||
|
*/
|
||||||
|
materialIndex: number | null
|
||||||
|
/** Optional: hit surface normal. Wall uses this for its interior/exterior split. */
|
||||||
|
normal?: readonly [number, number, number]
|
||||||
|
/** Optional: hit local position. Wall uses this to confirm the side. */
|
||||||
|
localPosition?: readonly [number, number, number]
|
||||||
|
/** Optional: name of the three.js object that received the hit. Stair uses this. */
|
||||||
|
hitObjectName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PaintPatchArgs = {
|
||||||
|
node: AnyNode
|
||||||
|
role: string
|
||||||
|
material: MaterialSchema | undefined
|
||||||
|
materialPreset: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PaintPreviewArgs = {
|
||||||
|
node: AnyNode
|
||||||
|
role: string
|
||||||
|
material: MaterialSchema | undefined
|
||||||
|
materialPreset: string | undefined
|
||||||
|
root: Object3D
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PaintEffectiveMaterialArgs = {
|
||||||
|
node: AnyNode
|
||||||
|
role: string
|
||||||
|
/** Snapshot of the scene `nodes` map — kinds whose effective material walks the parent chain (roof-segment → roof) read parents through it. */
|
||||||
|
nodes: Record<AnyNodeId, AnyNode>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kinds mounted on a roof segment via `roofSegmentId`. Presence of this
|
||||||
|
* capability tells the viewer's roof-merge loop two things:
|
||||||
|
*
|
||||||
|
* 1. **Dirty cascade.** When the accessory is dirtied (move / resize /
|
||||||
|
* reparent), the host segment's parent roof needs a re-merge —
|
||||||
|
* otherwise the merged shell shows the previous cut shape. The
|
||||||
|
* generic loop clears the accessory's dirty bit and queues the
|
||||||
|
* parent roof.
|
||||||
|
* 2. **Optional CSG cut.** When `buildCut` is set, the merge loop
|
||||||
|
* subtracts the returned geometry from the host segment's shin /
|
||||||
|
* deck / wall brushes so the accessory has a clean hole to poke
|
||||||
|
* through. Returned geometry is SEGMENT-LOCAL; the viewer welds
|
||||||
|
* vertices, attaches a single material group, and wraps it in a
|
||||||
|
* `three-bvh-csg` Brush — core stays free of three-bvh-csg deps
|
||||||
|
* and kinds don't need to import it.
|
||||||
|
*
|
||||||
|
* Use `buildCut` when the kind pokes THROUGH the roof (skylight,
|
||||||
|
* dormer). Kinds that sit ON TOP (vents, solar panels) declare the
|
||||||
|
* capability without `buildCut` — the cascade still fires but no CSG
|
||||||
|
* cut runs.
|
||||||
|
*/
|
||||||
|
export type RoofAccessoryConfig = {
|
||||||
|
buildCut?: (node: AnyNode, hostSegment: AnyNode) => BufferGeometry | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CapabilityCtx = { node: AnyNode }
|
export type CapabilityCtx = { node: AnyNode }
|
||||||
|
|||||||
@@ -24,8 +24,16 @@ export {
|
|||||||
resolveMaterial,
|
resolveMaterial,
|
||||||
TextureWrapMode,
|
TextureWrapMode,
|
||||||
} from './material'
|
} from './material'
|
||||||
|
export { BoxVentNode } from './nodes/box-vent'
|
||||||
export { BuildingNode } from './nodes/building'
|
export { BuildingNode } from './nodes/building'
|
||||||
export { CeilingNode } from './nodes/ceiling'
|
export { CeilingNode } from './nodes/ceiling'
|
||||||
|
export { ChimneyMaterialRole, ChimneyNode } from './nodes/chimney'
|
||||||
|
export {
|
||||||
|
DormerNode,
|
||||||
|
type DormerSurfaceMaterialRole,
|
||||||
|
type DormerSurfaceMaterialSpec,
|
||||||
|
getEffectiveDormerSurfaceMaterial,
|
||||||
|
} from './nodes/dormer'
|
||||||
export {
|
export {
|
||||||
COLUMN_PRESETS,
|
COLUMN_PRESETS,
|
||||||
ColumnBaseStyle,
|
ColumnBaseStyle,
|
||||||
@@ -71,10 +79,45 @@ export {
|
|||||||
export { LevelNode } from './nodes/level'
|
export { LevelNode } from './nodes/level'
|
||||||
export type { RoofSurfaceMaterialRole, RoofSurfaceMaterialSpec } from './nodes/roof'
|
export type { RoofSurfaceMaterialRole, RoofSurfaceMaterialSpec } from './nodes/roof'
|
||||||
export { getEffectiveRoofSurfaceMaterial, RoofNode } from './nodes/roof'
|
export { getEffectiveRoofSurfaceMaterial, RoofNode } from './nodes/roof'
|
||||||
export { RoofSegmentNode, RoofType } from './nodes/roof-segment'
|
export type {
|
||||||
|
RoofSegmentSurfaceMaterialRole,
|
||||||
|
RoofSegmentSurfaceMaterialSpec,
|
||||||
|
SegmentSlopeFrame,
|
||||||
|
} from './nodes/roof-segment'
|
||||||
|
export {
|
||||||
|
getActiveRoofHeight,
|
||||||
|
getEffectiveSegmentSurfaceMaterial,
|
||||||
|
getPitchFromActiveRoofHeight,
|
||||||
|
getSegmentSlopeFrame,
|
||||||
|
hasSegmentMaterialOverride,
|
||||||
|
ROOF_SHAPE_DEFAULTS,
|
||||||
|
RoofSegmentNode,
|
||||||
|
RoofType,
|
||||||
|
} from './nodes/roof-segment'
|
||||||
export { ScanNode } from './nodes/scan'
|
export { ScanNode } from './nodes/scan'
|
||||||
// Nodes
|
// Nodes
|
||||||
|
export { RidgeVentNode } from './nodes/ridge-vent'
|
||||||
export { ShelfNode } from './nodes/shelf'
|
export { ShelfNode } from './nodes/shelf'
|
||||||
|
export {
|
||||||
|
SKYLIGHT_TYPE_ORDER,
|
||||||
|
SKYLIGHT_TYPE_PRESETS,
|
||||||
|
SkylightMaterialRole,
|
||||||
|
SkylightNode,
|
||||||
|
SkylightOpeningSide,
|
||||||
|
SkylightSlideDirection,
|
||||||
|
type SkylightTypePreset,
|
||||||
|
SkylightType,
|
||||||
|
} from './nodes/skylight'
|
||||||
|
export {
|
||||||
|
SolarPanelMaterialRole,
|
||||||
|
SolarPanelNode,
|
||||||
|
} from './nodes/solar-panel'
|
||||||
|
export {
|
||||||
|
SOLAR_PANEL_PRESET_LABELS,
|
||||||
|
SOLAR_PANEL_PRESETS,
|
||||||
|
type SolarPanelPresetDims,
|
||||||
|
SolarPanelPresetKey,
|
||||||
|
} from '../solar-panel-presets'
|
||||||
export { SiteNode } from './nodes/site'
|
export { SiteNode } from './nodes/site'
|
||||||
export { SlabNode } from './nodes/slab'
|
export { SlabNode } from './nodes/slab'
|
||||||
export { SpawnNode } from './nodes/spawn'
|
export { SpawnNode } from './nodes/spawn'
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ export const MaterialTarget = z.enum([
|
|||||||
'door',
|
'door',
|
||||||
'window',
|
'window',
|
||||||
'shelf',
|
'shelf',
|
||||||
|
'chimney',
|
||||||
|
'skylight',
|
||||||
|
'dormer',
|
||||||
|
'box-vent',
|
||||||
|
'ridge-vent',
|
||||||
])
|
])
|
||||||
export type MaterialTarget = z.infer<typeof MaterialTarget>
|
export type MaterialTarget = z.infer<typeof MaterialTarget>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import dedent from 'dedent'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { BaseNode, nodeType, objectId } from '../base'
|
||||||
|
import { MaterialSchema } from '../material'
|
||||||
|
|
||||||
|
export const BoxVentNode = BaseNode.extend({
|
||||||
|
id: objectId('bvent'),
|
||||||
|
type: nodeType('box-vent'),
|
||||||
|
|
||||||
|
material: MaterialSchema.optional(),
|
||||||
|
// Default to the white preset so newly-placed vents read as clean
|
||||||
|
// painted metal — and so the paint inspector shows "White" as the
|
||||||
|
// current selection instead of an empty "no material" state, which
|
||||||
|
// made it look like the vent had nothing applied even though the
|
||||||
|
// renderer was falling back to white internally.
|
||||||
|
materialPreset: z.string().default('preset-white'),
|
||||||
|
|
||||||
|
roofSegmentId: z.string().optional(),
|
||||||
|
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||||
|
rotation: z.number().default(0),
|
||||||
|
|
||||||
|
width: z.number().default(0.4),
|
||||||
|
depth: z.number().default(0.4),
|
||||||
|
height: z.number().default(0.15),
|
||||||
|
// `cap` + `dome` only. Width of the flange skirt past the body. Ignored
|
||||||
|
// by the `box` style (no flange).
|
||||||
|
hoodOverhang: z.number().default(0.04),
|
||||||
|
// `cap`-only: how much the pyramid hood narrows at the top. 0 = no
|
||||||
|
// taper (flat-top column), 1 = comes to a point. ~0.4 reads as a
|
||||||
|
// classic attic-vent cap. Ignored by `box` / `dome`.
|
||||||
|
topTaper: z.number().default(0.4),
|
||||||
|
// `cap`-only: absolute height of the chamfered cap section. The body
|
||||||
|
// walls take up the rest of the total `height`. Together with
|
||||||
|
// `topTaper`, this controls the visible chamfer angle.
|
||||||
|
capHeight: z.number().default(0.07),
|
||||||
|
// `cap`-only: vertical air gap between the body's closed top and the
|
||||||
|
// cap's flange. Reads as the ventilation slot on real attic vents.
|
||||||
|
// 0 = cap sits flush on the body (the original cap shape).
|
||||||
|
capGap: z.number().default(0),
|
||||||
|
// `dome`-only: radial decay of the dome cap. 1 = perfect ellipsoid,
|
||||||
|
// <1 = soft pillow (wider for longer near the apex). Ignored by
|
||||||
|
// `box` / `cap`.
|
||||||
|
domeCurvature: z.number().default(0.65),
|
||||||
|
// `box`-only: how much the lower (smaller) riser is inset from the
|
||||||
|
// upper (larger) cover. 0 = same footprint as the cover.
|
||||||
|
baseInset: z.number().default(0.06),
|
||||||
|
// `box`-only: height of the lower riser. The upper cover takes up
|
||||||
|
// the remaining `height - baseHeight`.
|
||||||
|
baseHeight: z.number().default(0.04),
|
||||||
|
// `box`-only: radius of the corner chamfer on both stacked boxes.
|
||||||
|
// 0 = sharp corners.
|
||||||
|
cornerBevel: z.number().default(0.012),
|
||||||
|
|
||||||
|
// Migrate legacy style values (`standard` / `low-profile`) saved in
|
||||||
|
// older scenes to the new three-way enum so old data keeps parsing.
|
||||||
|
style: z.preprocess(
|
||||||
|
(value) => {
|
||||||
|
if (value === 'standard') return 'cap'
|
||||||
|
if (value === 'low-profile') return 'box'
|
||||||
|
return value
|
||||||
|
},
|
||||||
|
z.enum(['box', 'cap', 'dome']).default('cap'),
|
||||||
|
),
|
||||||
|
}).describe(
|
||||||
|
dedent`
|
||||||
|
Box vent node — a small louvered ventilation box that sits on a roof slope.
|
||||||
|
Often used in groups for attic exhaust ventilation. Parented to a
|
||||||
|
roof-segment; position is segment-local. Rotation is around the
|
||||||
|
segment's vertical axis (post-slope tilt).
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
|
||||||
|
export type BoxVentNode = z.infer<typeof BoxVentNode>
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import dedent from 'dedent'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { BaseNode, nodeType, objectId } from '../base'
|
||||||
|
import { MaterialSchema } from '../material'
|
||||||
|
|
||||||
|
export const ChimneyMaterialRole = z.enum(['body', 'top'])
|
||||||
|
export type ChimneyMaterialRole = z.infer<typeof ChimneyMaterialRole>
|
||||||
|
|
||||||
|
export const ChimneyNode = BaseNode.extend({
|
||||||
|
id: objectId('chimney'),
|
||||||
|
type: nodeType('chimney'),
|
||||||
|
|
||||||
|
// Body material — body, bands, flues, cricket.
|
||||||
|
material: MaterialSchema.optional(),
|
||||||
|
materialPreset: z.string().optional(),
|
||||||
|
// Top material — cap surface. Falls back to `material` if unset.
|
||||||
|
topMaterial: MaterialSchema.optional(),
|
||||||
|
topMaterialPreset: z.string().optional(),
|
||||||
|
|
||||||
|
// Host segment + segment-local 2D position. The Y component is
|
||||||
|
// ignored; placement is anchored to the segment's pitched surface
|
||||||
|
// by the renderer.
|
||||||
|
roofSegmentId: z.string().optional(),
|
||||||
|
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||||
|
rotation: z.number().default(0),
|
||||||
|
|
||||||
|
bodyShape: z.enum(['square', 'round']).default('square'),
|
||||||
|
bodyHollowDepth: z.number().default(0.6),
|
||||||
|
bodyHollowMargin: z.number().default(0.08),
|
||||||
|
width: z.number().default(0.6),
|
||||||
|
depth: z.number().default(0.6),
|
||||||
|
heightAboveRidge: z.number().default(1.0),
|
||||||
|
cutoutOffset: z.number().default(0),
|
||||||
|
// Chamfered corners on the body / cap / bands. 0 = sharp 90° corners
|
||||||
|
// (existing behaviour, kept as default so previously-saved chimneys
|
||||||
|
// look identical). A small positive value (~0.01-0.02 m) breaks up
|
||||||
|
// the silhouette and reads as a stone or chamfered-brick chimney.
|
||||||
|
// Square bodies only — round bodies have no corners to bevel.
|
||||||
|
cornerBevel: z.number().default(0),
|
||||||
|
|
||||||
|
cap: z.boolean().default(true),
|
||||||
|
capShape: z.enum(['none', 'sloped', 'flat', 'stepped']).default('sloped'),
|
||||||
|
capOverhang: z.number().default(0.04),
|
||||||
|
capThickness: z.number().default(0.08),
|
||||||
|
|
||||||
|
flueCount: z.number().int().min(0).max(4).default(1),
|
||||||
|
flueShape: z.enum(['round', 'square']).default('round'),
|
||||||
|
flueHeight: z.number().default(0.3),
|
||||||
|
flueDiameter: z.number().default(0.22),
|
||||||
|
flueSpacing: z.number().default(1),
|
||||||
|
flueWallThickness: z.number().default(0.02),
|
||||||
|
|
||||||
|
shoulderStyle: z.enum(['none', 'tapered', 'corbeled']).default('none'),
|
||||||
|
shoulderHeight: z.number().default(0.5),
|
||||||
|
shoulderExtent: z.number().default(0.1),
|
||||||
|
|
||||||
|
bandStyle: z.enum(['none', 'single', 'double']).default('none'),
|
||||||
|
bandHeight: z.number().default(0.1),
|
||||||
|
bandExtent: z.number().default(0.04),
|
||||||
|
bandOffset: z.number().default(0.4),
|
||||||
|
|
||||||
|
cricketStyle: z.enum(['none', 'simple']).default('none'),
|
||||||
|
cricketLength: z.number().default(0.6),
|
||||||
|
cricketHeight: z.number().default(0.4),
|
||||||
|
cricketSide: z.enum(['front', 'back']).default('front'),
|
||||||
|
|
||||||
|
panelStyle: z.enum(['none', 'rectangular']).default('none'),
|
||||||
|
panelDepth: z.number().default(0.03),
|
||||||
|
panelHeight: z.number().default(0.8),
|
||||||
|
panelOffsetTop: z.number().default(0.15),
|
||||||
|
panelMargin: z.number().default(0.1),
|
||||||
|
}).describe(
|
||||||
|
dedent`
|
||||||
|
Chimney — a vertical brick/stone stack hosted on a roof segment.
|
||||||
|
|
||||||
|
Position is segment-local (x along segment width, z along segment
|
||||||
|
depth). The Y component is ignored — placement anchors to the
|
||||||
|
segment's pitched surface. Rotation is yaw around the world-vertical
|
||||||
|
axis (chimneys stay vertical regardless of the slope).
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
|
||||||
|
export type ChimneyNode = z.infer<typeof ChimneyNode>
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import dedent from 'dedent'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { BaseNode, nodeType, objectId } from '../base'
|
||||||
|
import { MaterialSchema } from '../material'
|
||||||
|
import { RoofType } from './roof-segment'
|
||||||
|
|
||||||
|
export type DormerSurfaceMaterialRole = 'top' | 'side' | 'wall'
|
||||||
|
export type DormerSurfaceMaterialSpec = {
|
||||||
|
material?: z.infer<typeof MaterialSchema>
|
||||||
|
materialPreset?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default dormer dimensions and window controls. Values match the
|
||||||
|
* legacy archive so existing scenes don't shift visually.
|
||||||
|
*/
|
||||||
|
export const DORMER_DEFAULTS = {
|
||||||
|
WIDTH: 1.21,
|
||||||
|
DEPTH: 1.55,
|
||||||
|
WALL_HEIGHT: 0,
|
||||||
|
ROOF_HEIGHT: 0.49,
|
||||||
|
WALL_SKIRT_HEIGHT: 2.73,
|
||||||
|
WINDOW_WIDTH: 0.76,
|
||||||
|
WINDOW_HEIGHT: 0.68,
|
||||||
|
WINDOW_OFFSET_X: 0.02,
|
||||||
|
WINDOW_OFFSET_Y: 0.99,
|
||||||
|
WINDOW_FRAME_THICKNESS: 0.05,
|
||||||
|
WINDOW_FRAME_DEPTH: 0.06,
|
||||||
|
WINDOW_COLUMNS: 3,
|
||||||
|
WINDOW_ROWS: 3,
|
||||||
|
WINDOW_DIVIDER_THICKNESS: 0.02,
|
||||||
|
WINDOW_ARCH_HEIGHT: 0.35,
|
||||||
|
WINDOW_CORNER_RADIUS: 0.15,
|
||||||
|
WINDOW_SILL_DEPTH: 0.08,
|
||||||
|
WINDOW_SILL_THICKNESS: 0.03,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const DEFAULT_CORNER_RADII: [number, number, number, number] = [
|
||||||
|
DORMER_DEFAULTS.WINDOW_CORNER_RADIUS,
|
||||||
|
DORMER_DEFAULTS.WINDOW_CORNER_RADIUS,
|
||||||
|
DORMER_DEFAULTS.WINDOW_CORNER_RADIUS,
|
||||||
|
DORMER_DEFAULTS.WINDOW_CORNER_RADIUS,
|
||||||
|
]
|
||||||
|
|
||||||
|
export const DormerNode = BaseNode.extend({
|
||||||
|
id: objectId('dormer'),
|
||||||
|
type: nodeType('dormer'),
|
||||||
|
|
||||||
|
material: MaterialSchema.optional(),
|
||||||
|
materialPreset: z.string().optional(),
|
||||||
|
topMaterial: MaterialSchema.optional(),
|
||||||
|
topMaterialPreset: z.string().optional(),
|
||||||
|
sideMaterial: MaterialSchema.optional(),
|
||||||
|
sideMaterialPreset: z.string().optional(),
|
||||||
|
wallMaterial: MaterialSchema.optional(),
|
||||||
|
wallMaterialPreset: z.string().optional(),
|
||||||
|
|
||||||
|
roofSegmentId: z.string().optional(),
|
||||||
|
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||||
|
rotation: z.number().default(0),
|
||||||
|
|
||||||
|
width: z.number().default(DORMER_DEFAULTS.WIDTH),
|
||||||
|
depth: z.number().default(DORMER_DEFAULTS.DEPTH),
|
||||||
|
height: z.number().default(DORMER_DEFAULTS.WALL_HEIGHT),
|
||||||
|
|
||||||
|
roofType: RoofType.default('gable'),
|
||||||
|
roofHeight: z.number().default(DORMER_DEFAULTS.ROOF_HEIGHT),
|
||||||
|
|
||||||
|
// Height of the hung wall (the "skirt") that extends below the eave
|
||||||
|
// into the host roof — this is the wall area the window opening is
|
||||||
|
// cut through. Larger values let the dormer host taller windows.
|
||||||
|
wallSkirtHeight: z.number().default(DORMER_DEFAULTS.WALL_SKIRT_HEIGHT),
|
||||||
|
|
||||||
|
// Window is rendered as parametric geometry on the dormer's front
|
||||||
|
// face — not a child node. The fields below mirror the legacy panel
|
||||||
|
// controls; geometry beyond the simple opening box is deferred.
|
||||||
|
windowWidth: z.number().default(DORMER_DEFAULTS.WINDOW_WIDTH),
|
||||||
|
windowHeight: z.number().default(DORMER_DEFAULTS.WINDOW_HEIGHT),
|
||||||
|
windowOffsetX: z.number().default(DORMER_DEFAULTS.WINDOW_OFFSET_X),
|
||||||
|
windowOffsetY: z.number().default(DORMER_DEFAULTS.WINDOW_OFFSET_Y),
|
||||||
|
windowFrameThickness: z.number().default(DORMER_DEFAULTS.WINDOW_FRAME_THICKNESS),
|
||||||
|
windowFrameDepth: z.number().default(DORMER_DEFAULTS.WINDOW_FRAME_DEPTH),
|
||||||
|
windowColumns: z.number().int().min(1).max(8).default(DORMER_DEFAULTS.WINDOW_COLUMNS),
|
||||||
|
windowRows: z.number().int().min(1).max(8).default(DORMER_DEFAULTS.WINDOW_ROWS),
|
||||||
|
windowDividerThickness: z.number().default(DORMER_DEFAULTS.WINDOW_DIVIDER_THICKNESS),
|
||||||
|
windowShape: z.enum(['rectangle', 'rounded', 'arch']).default('rectangle'),
|
||||||
|
windowArchHeight: z.number().default(DORMER_DEFAULTS.WINDOW_ARCH_HEIGHT),
|
||||||
|
// Single source of truth for the rounded-shape corner radii. Tuple is
|
||||||
|
// [topLeft, topRight, bottomRight, bottomLeft]. "All vs Individual"
|
||||||
|
// is a UI-only view mode derived from whether the tuple is uniform.
|
||||||
|
windowCornerRadii: z
|
||||||
|
.tuple([z.number(), z.number(), z.number(), z.number()])
|
||||||
|
.default(DEFAULT_CORNER_RADII),
|
||||||
|
windowSill: z.boolean().default(true),
|
||||||
|
windowSillDepth: z.number().default(DORMER_DEFAULTS.WINDOW_SILL_DEPTH),
|
||||||
|
windowSillThickness: z.number().default(DORMER_DEFAULTS.WINDOW_SILL_THICKNESS),
|
||||||
|
}).describe(
|
||||||
|
dedent`
|
||||||
|
Dormer — a small house-shaped protrusion sitting on top of a roof
|
||||||
|
segment. width × depth × height defines the box base; roofType and
|
||||||
|
roofHeight define the dormer's own roof shape. The window opening
|
||||||
|
is parametric geometry on the dormer's front face, not a hosted
|
||||||
|
child node.
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
|
||||||
|
export type DormerNode = z.infer<typeof DormerNode>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-surface material resolution. Fall-through order:
|
||||||
|
* top → topMaterial[Preset] → legacy
|
||||||
|
* side → sideMaterial[Preset] → wallMaterial[Preset] → legacy
|
||||||
|
* wall → wallMaterial[Preset] → sideMaterial[Preset] → legacy
|
||||||
|
* where legacy is `node.material` / `node.materialPreset`.
|
||||||
|
*/
|
||||||
|
export function getEffectiveDormerSurfaceMaterial(
|
||||||
|
node: DormerNode,
|
||||||
|
role: DormerSurfaceMaterialRole,
|
||||||
|
): DormerSurfaceMaterialSpec {
|
||||||
|
const top: DormerSurfaceMaterialSpec = {
|
||||||
|
material: node.topMaterial,
|
||||||
|
materialPreset: node.topMaterialPreset,
|
||||||
|
}
|
||||||
|
const side: DormerSurfaceMaterialSpec = {
|
||||||
|
material: node.sideMaterial,
|
||||||
|
materialPreset: node.sideMaterialPreset,
|
||||||
|
}
|
||||||
|
const wall: DormerSurfaceMaterialSpec = {
|
||||||
|
material: node.wallMaterial,
|
||||||
|
materialPreset: node.wallMaterialPreset,
|
||||||
|
}
|
||||||
|
const legacy: DormerSurfaceMaterialSpec = {
|
||||||
|
material: node.material,
|
||||||
|
materialPreset: node.materialPreset,
|
||||||
|
}
|
||||||
|
const has = (spec: DormerSurfaceMaterialSpec) =>
|
||||||
|
spec.material !== undefined || typeof spec.materialPreset === 'string'
|
||||||
|
|
||||||
|
if (role === 'top') return has(top) ? top : legacy
|
||||||
|
if (role === 'side') return has(side) ? side : has(wall) ? wall : legacy
|
||||||
|
return has(wall) ? wall : has(side) ? side : legacy
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import dedent from 'dedent'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { BaseNode, nodeType, objectId } from '../base'
|
||||||
|
import { MaterialSchema } from '../material'
|
||||||
|
|
||||||
|
export const RidgeVentNode = BaseNode.extend({
|
||||||
|
id: objectId('rvent'),
|
||||||
|
type: nodeType('ridge-vent'),
|
||||||
|
|
||||||
|
material: MaterialSchema.optional(),
|
||||||
|
// See note on box-vent: default to white so the paint inspector
|
||||||
|
// reflects the current visual state instead of "no material".
|
||||||
|
materialPreset: z.string().default('preset-white'),
|
||||||
|
|
||||||
|
roofSegmentId: z.string().optional(),
|
||||||
|
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||||
|
rotation: z.number().default(0),
|
||||||
|
|
||||||
|
length: z.number().default(2.0),
|
||||||
|
width: z.number().default(0.3),
|
||||||
|
height: z.number().default(0.08),
|
||||||
|
|
||||||
|
style: z.enum(['standard', 'shingled', 'metal']).default('standard'),
|
||||||
|
endCaps: z.boolean().default(true),
|
||||||
|
}).describe(
|
||||||
|
dedent`
|
||||||
|
Ridge vent — a ventilation strip that sits along the ridge (peak) of a
|
||||||
|
roof segment. Parented to a roof-segment; position is segment-local.
|
||||||
|
- length: extent along the ridge
|
||||||
|
- width: vent width straddling the ridge center
|
||||||
|
- height: profile height above the ridge surface
|
||||||
|
- style: standard (curved cap) / shingled / metal
|
||||||
|
- endCaps: cap both ends or leave open
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
|
||||||
|
export type RidgeVentNode = z.infer<typeof RidgeVentNode>
|
||||||
@@ -1,17 +1,48 @@
|
|||||||
import dedent from 'dedent'
|
import dedent from 'dedent'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { BaseNode, nodeType, objectId } from '../base'
|
import { BaseNode, nodeType, objectId } from '../base'
|
||||||
|
import type { MaterialSchema as MaterialSchemaType } from '../material'
|
||||||
import { MaterialSchema } from '../material'
|
import { MaterialSchema } from '../material'
|
||||||
|
|
||||||
export const RoofType = z.enum(['hip', 'gable', 'shed', 'gambrel', 'dutch', 'mansard', 'flat'])
|
export const RoofType = z.enum(['hip', 'gable', 'shed', 'gambrel', 'dutch', 'mansard', 'flat'])
|
||||||
|
|
||||||
export type RoofType = z.infer<typeof RoofType>
|
export type RoofType = z.infer<typeof RoofType>
|
||||||
|
|
||||||
|
// Default shape ratios. Tuning these used to require editing the geometry
|
||||||
|
// code in two places; they are now schema fields with these defaults.
|
||||||
|
export const ROOF_SHAPE_DEFAULTS = {
|
||||||
|
/** Gambrel: lower (steep) face occupies this fraction of the horizontal half-depth. */
|
||||||
|
gambrelLowerWidthRatio: 0.5,
|
||||||
|
/** Gambrel: lower (steep) face rises this fraction of the way to the peak. */
|
||||||
|
gambrelLowerHeightRatio: 0.6,
|
||||||
|
/** Mansard: steep face occupies this fraction of `min(width, depth)`. */
|
||||||
|
mansardSteepWidthRatio: 0.15,
|
||||||
|
/** Mansard: steep face rises this fraction of the way to the peak. */
|
||||||
|
mansardSteepHeightRatio: 0.7,
|
||||||
|
/** Dutch: hip face occupies this fraction of `min(width, depth)`. */
|
||||||
|
dutchHipWidthRatio: 0.25,
|
||||||
|
/** Dutch: hip face rises this fraction of the way to the peak. */
|
||||||
|
dutchHipHeightRatio: 0.5,
|
||||||
|
} as const
|
||||||
|
|
||||||
export const RoofSegmentNode = BaseNode.extend({
|
export const RoofSegmentNode = BaseNode.extend({
|
||||||
id: objectId('rseg'),
|
id: objectId('rseg'),
|
||||||
type: nodeType('roof-segment'),
|
type: nodeType('roof-segment'),
|
||||||
|
// Catch-all material — splatted across all 4 slots in the renderer
|
||||||
|
// when no role-specific override is set. Kept for back-compat with
|
||||||
|
// older scenes; new paint operations should prefer the role fields.
|
||||||
material: MaterialSchema.optional(),
|
material: MaterialSchema.optional(),
|
||||||
materialPreset: z.string().optional(),
|
materialPreset: z.string().optional(),
|
||||||
|
// Role-specific overrides mirror the parent roof's surface roles.
|
||||||
|
// When set they win over the segment's catch-all `material` and over
|
||||||
|
// the parent roof's role / catch-all materials. Resolution order:
|
||||||
|
// segment role → segment catch-all → roof role → roof catch-all.
|
||||||
|
topMaterial: MaterialSchema.optional(),
|
||||||
|
topMaterialPreset: z.string().optional(),
|
||||||
|
edgeMaterial: MaterialSchema.optional(),
|
||||||
|
edgeMaterialPreset: z.string().optional(),
|
||||||
|
wallMaterial: MaterialSchema.optional(),
|
||||||
|
wallMaterialPreset: z.string().optional(),
|
||||||
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||||
// Rotation around Y axis in radians
|
// Rotation around Y axis in radians
|
||||||
rotation: z.number().default(0),
|
rotation: z.number().default(0),
|
||||||
@@ -20,14 +51,60 @@ export const RoofSegmentNode = BaseNode.extend({
|
|||||||
// Footprint dimensions
|
// Footprint dimensions
|
||||||
width: z.number().default(8),
|
width: z.number().default(8),
|
||||||
depth: z.number().default(6),
|
depth: z.number().default(6),
|
||||||
// Vertical dimensions
|
// Wall height beneath the roof
|
||||||
wallHeight: z.number().default(0.5),
|
wallHeight: z.number().default(0.5),
|
||||||
roofHeight: z.number().default(2.5),
|
// Roof pitch in degrees — angle of the primary slope face.
|
||||||
|
// For gable/hip/shed this is the only slope; for gambrel/mansard/dutch
|
||||||
|
// it is the steep (lower) slope. The overall peak height is derived
|
||||||
|
// from pitch + footprint + roofType via getActiveRoofHeight().
|
||||||
|
pitch: z.number().min(0).max(85).default(40),
|
||||||
// Structure thicknesses
|
// Structure thicknesses
|
||||||
wallThickness: z.number().default(0.1),
|
wallThickness: z.number().default(0.1),
|
||||||
deckThickness: z.number().default(0.1),
|
deckThickness: z.number().default(0.1),
|
||||||
overhang: z.number().default(0.3),
|
overhang: z.number().default(0.3),
|
||||||
shingleThickness: z.number().default(0.05),
|
shingleThickness: z.number().default(0.05),
|
||||||
|
// Shape-specific ratios. Only the pair matching `roofType` is read; the
|
||||||
|
// rest are inert. Defined on every segment so the panel can flip
|
||||||
|
// roofType without losing the previous shape's tuning.
|
||||||
|
gambrelLowerWidthRatio: z
|
||||||
|
.number()
|
||||||
|
.min(0.1)
|
||||||
|
.max(0.9)
|
||||||
|
.default(ROOF_SHAPE_DEFAULTS.gambrelLowerWidthRatio),
|
||||||
|
gambrelLowerHeightRatio: z
|
||||||
|
.number()
|
||||||
|
.min(0.1)
|
||||||
|
.max(0.9)
|
||||||
|
.default(ROOF_SHAPE_DEFAULTS.gambrelLowerHeightRatio),
|
||||||
|
mansardSteepWidthRatio: z
|
||||||
|
.number()
|
||||||
|
.min(0.05)
|
||||||
|
.max(0.45)
|
||||||
|
.default(ROOF_SHAPE_DEFAULTS.mansardSteepWidthRatio),
|
||||||
|
mansardSteepHeightRatio: z
|
||||||
|
.number()
|
||||||
|
.min(0.1)
|
||||||
|
.max(0.9)
|
||||||
|
.default(ROOF_SHAPE_DEFAULTS.mansardSteepHeightRatio),
|
||||||
|
dutchHipWidthRatio: z
|
||||||
|
.number()
|
||||||
|
.min(0.05)
|
||||||
|
.max(0.45)
|
||||||
|
.default(ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio),
|
||||||
|
dutchHipHeightRatio: z
|
||||||
|
.number()
|
||||||
|
.min(0.1)
|
||||||
|
.max(0.9)
|
||||||
|
.default(ROOF_SHAPE_DEFAULTS.dutchHipHeightRatio),
|
||||||
|
// Hosted accessories — chimney, dormer, skylight, box-vent,
|
||||||
|
// ridge-vent, solar-panel. Each accessory's `parentId` points back
|
||||||
|
// here; the segment renderer mounts them recursively via
|
||||||
|
// `<NodeRenderer>` so they inherit the segment's transform stack.
|
||||||
|
// Required for `createNode(child, segmentId)` to append the child
|
||||||
|
// to this array — see
|
||||||
|
// `wiki/architecture/node-definitions.md` ("Host kinds need a
|
||||||
|
// `children` field on the schema").
|
||||||
|
children: z.array(z.string()).default([]),
|
||||||
}).describe(
|
}).describe(
|
||||||
dedent`
|
dedent`
|
||||||
Roof segment node - an individual roof module within a roof group.
|
Roof segment node - an individual roof module within a roof group.
|
||||||
@@ -36,11 +113,240 @@ export const RoofSegmentNode = BaseNode.extend({
|
|||||||
- roofType: hip, gable, shed, gambrel, dutch, mansard, flat
|
- roofType: hip, gable, shed, gambrel, dutch, mansard, flat
|
||||||
- width/depth: footprint dimensions
|
- width/depth: footprint dimensions
|
||||||
- wallHeight: height of walls below the roof
|
- wallHeight: height of walls below the roof
|
||||||
- roofHeight: height of the roof peak above the walls
|
- pitch: roof slope in degrees (angle of the primary slope face)
|
||||||
- wallThickness/deckThickness: structural thicknesses
|
- wallThickness/deckThickness: structural thicknesses
|
||||||
- overhang: eave overhang distance
|
- overhang: eave overhang distance
|
||||||
- shingleThickness: outer shingle layer thickness
|
- shingleThickness: outer shingle layer thickness
|
||||||
|
- gambrelLowerWidthRatio / gambrelLowerHeightRatio: kink position on gambrel roofs
|
||||||
|
- mansardSteepWidthRatio / mansardSteepHeightRatio: waist position on mansard roofs
|
||||||
|
- dutchHipWidthRatio / dutchHipHeightRatio: hip-to-gable split on dutch roofs
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
|
|
||||||
export type RoofSegmentNode = z.infer<typeof RoofSegmentNode>
|
export type RoofSegmentNode = z.infer<typeof RoofSegmentNode>
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Pitch ↔ roof-peak height
|
||||||
|
//
|
||||||
|
// Pitch is the angle of the primary slope face. For each roof type the
|
||||||
|
// "primary slope" maps to a specific (rise, run) pair on the constructed
|
||||||
|
// geometry — gambrel/mansard/dutch have a multi-face slope and we standardise
|
||||||
|
// on the lower / steep face. These helpers are the single conversion point;
|
||||||
|
// all geometry consumers should call `getActiveRoofHeight` instead of reading
|
||||||
|
// a stored roofHeight field.
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Shape of the per-type ratios consumed by the slope helpers. */
|
||||||
|
type ShapeRatios = {
|
||||||
|
gambrelLowerWidthRatio: number
|
||||||
|
gambrelLowerHeightRatio: number
|
||||||
|
mansardSteepWidthRatio: number
|
||||||
|
mansardSteepHeightRatio: number
|
||||||
|
dutchHipWidthRatio: number
|
||||||
|
dutchHipHeightRatio: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type PitchInputs = {
|
||||||
|
roofType: RoofType
|
||||||
|
width: number
|
||||||
|
depth: number
|
||||||
|
} & Partial<ShapeRatios>
|
||||||
|
|
||||||
|
function withRatioDefaults(input: PitchInputs): PitchInputs & ShapeRatios {
|
||||||
|
return {
|
||||||
|
...input,
|
||||||
|
gambrelLowerWidthRatio:
|
||||||
|
input.gambrelLowerWidthRatio ?? ROOF_SHAPE_DEFAULTS.gambrelLowerWidthRatio,
|
||||||
|
gambrelLowerHeightRatio:
|
||||||
|
input.gambrelLowerHeightRatio ?? ROOF_SHAPE_DEFAULTS.gambrelLowerHeightRatio,
|
||||||
|
mansardSteepWidthRatio:
|
||||||
|
input.mansardSteepWidthRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepWidthRatio,
|
||||||
|
mansardSteepHeightRatio:
|
||||||
|
input.mansardSteepHeightRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepHeightRatio,
|
||||||
|
dutchHipWidthRatio: input.dutchHipWidthRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio,
|
||||||
|
dutchHipHeightRatio:
|
||||||
|
input.dutchHipHeightRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipHeightRatio,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPrimarySlopeRun(input: PitchInputs & ShapeRatios): number {
|
||||||
|
const min = Math.min(input.width, input.depth)
|
||||||
|
switch (input.roofType) {
|
||||||
|
case 'shed':
|
||||||
|
return input.depth
|
||||||
|
case 'gable':
|
||||||
|
return input.depth / 2
|
||||||
|
case 'gambrel':
|
||||||
|
return (input.depth / 2) * input.gambrelLowerWidthRatio
|
||||||
|
case 'mansard':
|
||||||
|
return min * input.mansardSteepWidthRatio
|
||||||
|
case 'dutch':
|
||||||
|
return min * input.dutchHipWidthRatio
|
||||||
|
case 'hip':
|
||||||
|
default:
|
||||||
|
return min / 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fraction of the overall peak height that is taken up by the primary slope.
|
||||||
|
function getPrimarySlopeRiseFraction(input: PitchInputs & ShapeRatios): number {
|
||||||
|
switch (input.roofType) {
|
||||||
|
case 'gambrel':
|
||||||
|
return input.gambrelLowerHeightRatio
|
||||||
|
case 'mansard':
|
||||||
|
return input.mansardSteepHeightRatio
|
||||||
|
case 'dutch':
|
||||||
|
return input.dutchHipHeightRatio
|
||||||
|
default:
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SegmentSlopeFrame = {
|
||||||
|
/** Horizontal half-span of the primary slope face (eave-to-ridge). */
|
||||||
|
run: number
|
||||||
|
/** Vertical height of the primary slope face. */
|
||||||
|
rise: number
|
||||||
|
/** tan(pitch). 0 for flat or zero-pitch segments. */
|
||||||
|
tanTheta: number
|
||||||
|
/** cos(pitch). 1 for flat or zero-pitch segments. */
|
||||||
|
cosTheta: number
|
||||||
|
/** sin(pitch). 0 for flat or zero-pitch segments. */
|
||||||
|
sinTheta: number
|
||||||
|
/** Overall eave-to-peak height of the assembled roof. */
|
||||||
|
activeRh: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One stop for the slope math every roof-segment consumer needs. Builds
|
||||||
|
* `run`, `rise`, the trig triple, and the overall peak height from the
|
||||||
|
* segment's pitch + footprint + roofType. Before this helper existed,
|
||||||
|
* the table was duplicated in three places (the brush builder, the
|
||||||
|
* skylight surface-frame routine, and the segment-hit raycaster) and
|
||||||
|
* silently drifted when a new roof type was added.
|
||||||
|
*/
|
||||||
|
export function getSegmentSlopeFrame(
|
||||||
|
node: Pick<RoofSegmentNode, 'roofType' | 'pitch' | 'width' | 'depth'> &
|
||||||
|
Partial<ShapeRatios>,
|
||||||
|
): SegmentSlopeFrame {
|
||||||
|
const ratios = withRatioDefaults(node)
|
||||||
|
const run = getPrimarySlopeRun(ratios)
|
||||||
|
if (node.roofType === 'flat' || node.pitch <= 0) {
|
||||||
|
return { run, rise: 0, tanTheta: 0, cosTheta: 1, sinTheta: 0, activeRh: 0 }
|
||||||
|
}
|
||||||
|
const pitchRad = (node.pitch * Math.PI) / 180
|
||||||
|
const tanTheta = Math.tan(pitchRad)
|
||||||
|
const cosTheta = Math.cos(pitchRad) || 1
|
||||||
|
const sinTheta = Math.sin(pitchRad)
|
||||||
|
const rise = run * tanTheta
|
||||||
|
const activeRh = rise / getPrimarySlopeRiseFraction(ratios)
|
||||||
|
return { run, rise, tanTheta, cosTheta, sinTheta, activeRh }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The eave-to-peak height of the assembled segment, derived from pitch +
|
||||||
|
* footprint + roofType. Replaces the legacy `roofHeight` field on the node.
|
||||||
|
*/
|
||||||
|
export function getActiveRoofHeight(
|
||||||
|
node: Parameters<typeof getSegmentSlopeFrame>[0],
|
||||||
|
): number {
|
||||||
|
return getSegmentSlopeFrame(node).activeRh
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inverse of `getActiveRoofHeight` — recover the pitch a legacy
|
||||||
|
* `roofHeight` value would correspond to. Used by the scene migration.
|
||||||
|
* Ratio overrides are optional and default to the shape defaults.
|
||||||
|
*/
|
||||||
|
export function getPitchFromActiveRoofHeight(
|
||||||
|
input: PitchInputs & { roofHeight: number },
|
||||||
|
): number {
|
||||||
|
if (input.roofType === 'flat' || input.roofHeight <= 0) return 0
|
||||||
|
const ratios = withRatioDefaults(input)
|
||||||
|
const run = getPrimarySlopeRun(ratios)
|
||||||
|
if (run <= 0) return 0
|
||||||
|
const rise = input.roofHeight * getPrimarySlopeRiseFraction(ratios)
|
||||||
|
return (Math.atan2(rise, run) * 180) / Math.PI
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Per-segment surface materials
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type RoofSegmentSurfaceMaterialRole = 'top' | 'edge' | 'wall'
|
||||||
|
export type RoofSegmentSurfaceMaterialSpec = {
|
||||||
|
material?: MaterialSchemaType
|
||||||
|
materialPreset?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLegacyRoofSegmentSurfaceMaterial(
|
||||||
|
node: RoofSegmentNode,
|
||||||
|
): RoofSegmentSurfaceMaterialSpec {
|
||||||
|
return {
|
||||||
|
material: node.material,
|
||||||
|
materialPreset: typeof node.materialPreset === 'string' ? node.materialPreset : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the segment-level material for one of the three surface roles.
|
||||||
|
* Falls back through: role-specific field → catch-all `material`. Pass the
|
||||||
|
* parent roof to `parentFallback` when you want the roof's role material
|
||||||
|
* to fill in for an unset segment slot — typical from the renderer.
|
||||||
|
*/
|
||||||
|
export function getEffectiveSegmentSurfaceMaterial(
|
||||||
|
node: RoofSegmentNode,
|
||||||
|
role: RoofSegmentSurfaceMaterialRole,
|
||||||
|
parentFallback?: RoofSegmentSurfaceMaterialSpec,
|
||||||
|
): RoofSegmentSurfaceMaterialSpec {
|
||||||
|
if (role === 'top') {
|
||||||
|
if (node.topMaterial !== undefined || typeof node.topMaterialPreset === 'string') {
|
||||||
|
return {
|
||||||
|
material: node.topMaterial,
|
||||||
|
materialPreset:
|
||||||
|
typeof node.topMaterialPreset === 'string' ? node.topMaterialPreset : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (role === 'edge') {
|
||||||
|
if (node.edgeMaterial !== undefined || typeof node.edgeMaterialPreset === 'string') {
|
||||||
|
return {
|
||||||
|
material: node.edgeMaterial,
|
||||||
|
materialPreset:
|
||||||
|
typeof node.edgeMaterialPreset === 'string' ? node.edgeMaterialPreset : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (role === 'wall') {
|
||||||
|
if (node.wallMaterial !== undefined || typeof node.wallMaterialPreset === 'string') {
|
||||||
|
return {
|
||||||
|
material: node.wallMaterial,
|
||||||
|
materialPreset:
|
||||||
|
typeof node.wallMaterialPreset === 'string' ? node.wallMaterialPreset : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacy = getLegacyRoofSegmentSurfaceMaterial(node)
|
||||||
|
if (legacy.material !== undefined || legacy.materialPreset !== undefined) return legacy
|
||||||
|
|
||||||
|
return parentFallback ?? { material: undefined, materialPreset: undefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when the segment has any segment-level material override —
|
||||||
|
* either the legacy catch-all or any of the three role-specific fields.
|
||||||
|
* Used by `RoofRenderer` and `updateMergedRoofGeometry` to decide whether
|
||||||
|
* the segment should be drawn as its own mesh or folded into the merged
|
||||||
|
* shell.
|
||||||
|
*/
|
||||||
|
export function hasSegmentMaterialOverride(node: RoofSegmentNode): boolean {
|
||||||
|
return (
|
||||||
|
node.material !== undefined ||
|
||||||
|
typeof node.materialPreset === 'string' ||
|
||||||
|
node.topMaterial !== undefined ||
|
||||||
|
typeof node.topMaterialPreset === 'string' ||
|
||||||
|
node.edgeMaterial !== undefined ||
|
||||||
|
typeof node.edgeMaterialPreset === 'string' ||
|
||||||
|
node.wallMaterial !== undefined ||
|
||||||
|
typeof node.wallMaterialPreset === 'string'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import dedent from 'dedent'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { BaseNode, nodeType, objectId } from '../base'
|
||||||
|
import { MaterialSchema } from '../material'
|
||||||
|
|
||||||
|
export const SkylightMaterialRole = z.enum(['frame', 'glass'])
|
||||||
|
export type SkylightMaterialRole = z.infer<typeof SkylightMaterialRole>
|
||||||
|
|
||||||
|
export const SKYLIGHT_TYPE_ORDER = ['flat', 'walk-on', 'lantern', 'opening', 'sliding'] as const
|
||||||
|
|
||||||
|
export const SkylightType = z.enum(SKYLIGHT_TYPE_ORDER)
|
||||||
|
export type SkylightType = (typeof SKYLIGHT_TYPE_ORDER)[number]
|
||||||
|
|
||||||
|
export const SkylightOpeningSide = z.enum(['top', 'bottom', 'left', 'right'])
|
||||||
|
export type SkylightOpeningSide = z.infer<typeof SkylightOpeningSide>
|
||||||
|
|
||||||
|
export const SkylightSlideDirection = z.enum(['x', 'z'])
|
||||||
|
export type SkylightSlideDirection = z.infer<typeof SkylightSlideDirection>
|
||||||
|
|
||||||
|
export type SkylightTypePreset = {
|
||||||
|
label: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
frameThickness: number
|
||||||
|
frameDepth: number
|
||||||
|
glassThickness: number
|
||||||
|
curb: boolean
|
||||||
|
curbHeight: number
|
||||||
|
cutoutOffset: number
|
||||||
|
lanternHeight: number
|
||||||
|
lanternTopScale: number
|
||||||
|
openingAngle: number
|
||||||
|
openingSide: SkylightOpeningSide
|
||||||
|
operationState: number
|
||||||
|
motorHousing: boolean
|
||||||
|
slideFraction: number
|
||||||
|
slideDirection: SkylightSlideDirection
|
||||||
|
trackWidth: number
|
||||||
|
motorHousingSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SKYLIGHT_TYPE_PRESETS = {
|
||||||
|
flat: {
|
||||||
|
label: 'Flat roof skylight',
|
||||||
|
width: 0.9, height: 1.2, frameThickness: 0.055, frameDepth: 0.08,
|
||||||
|
glassThickness: 0.012, curb: true, curbHeight: 0.08, cutoutOffset: 0,
|
||||||
|
lanternHeight: 0.25, lanternTopScale: 0, openingAngle: 0,
|
||||||
|
openingSide: 'top', operationState: 0, motorHousing: false,
|
||||||
|
slideFraction: 0, slideDirection: 'z', trackWidth: 0.04, motorHousingSize: 0.08,
|
||||||
|
},
|
||||||
|
'walk-on': {
|
||||||
|
label: 'Walk-on rooflight',
|
||||||
|
width: 1.2, height: 1.8, frameThickness: 0.035, frameDepth: 0.045,
|
||||||
|
glassThickness: 0.04, curb: false, curbHeight: 0, cutoutOffset: 0,
|
||||||
|
lanternHeight: 0.25, lanternTopScale: 0, openingAngle: 0,
|
||||||
|
openingSide: 'top', operationState: 0, motorHousing: false,
|
||||||
|
slideFraction: 0, slideDirection: 'z', trackWidth: 0.04, motorHousingSize: 0.08,
|
||||||
|
},
|
||||||
|
lantern: {
|
||||||
|
label: 'Roof lantern',
|
||||||
|
width: 1.4, height: 1.4, frameThickness: 0.06, frameDepth: 0.08,
|
||||||
|
glassThickness: 0.012, curb: true, curbHeight: 0.16, cutoutOffset: 0,
|
||||||
|
lanternHeight: 0.45, lanternTopScale: 0, openingAngle: 0,
|
||||||
|
openingSide: 'top', operationState: 0, motorHousing: false,
|
||||||
|
slideFraction: 0, slideDirection: 'z', trackWidth: 0.04, motorHousingSize: 0.08,
|
||||||
|
},
|
||||||
|
opening: {
|
||||||
|
label: 'Opening skylight',
|
||||||
|
width: 0.9, height: 1.2, frameThickness: 0.06, frameDepth: 0.035,
|
||||||
|
glassThickness: 0.014, curb: true, curbHeight: 0.1, cutoutOffset: 0,
|
||||||
|
lanternHeight: 0.25, lanternTopScale: 0, openingAngle: Math.PI / 8,
|
||||||
|
openingSide: 'top', operationState: 1, motorHousing: false,
|
||||||
|
slideFraction: 0, slideDirection: 'z', trackWidth: 0.04, motorHousingSize: 0.08,
|
||||||
|
},
|
||||||
|
sliding: {
|
||||||
|
label: 'Sliding skylight',
|
||||||
|
width: 1.4, height: 1.1, frameThickness: 0.055, frameDepth: 0.08,
|
||||||
|
glassThickness: 0.012, curb: true, curbHeight: 0.08, cutoutOffset: 0,
|
||||||
|
lanternHeight: 0.25, lanternTopScale: 0, openingAngle: 0,
|
||||||
|
openingSide: 'top', operationState: 0.35, motorHousing: false,
|
||||||
|
slideFraction: 0.35, slideDirection: 'x', trackWidth: 0.045, motorHousingSize: 0.08,
|
||||||
|
},
|
||||||
|
} as const satisfies Record<SkylightType, SkylightTypePreset>
|
||||||
|
|
||||||
|
const DEFAULT = SKYLIGHT_TYPE_PRESETS.flat
|
||||||
|
|
||||||
|
export const SkylightNode = BaseNode.extend({
|
||||||
|
id: objectId('skylight'),
|
||||||
|
type: nodeType('skylight'),
|
||||||
|
|
||||||
|
material: MaterialSchema.optional(),
|
||||||
|
materialPreset: z.string().optional(),
|
||||||
|
glassMaterial: MaterialSchema.optional(),
|
||||||
|
glassMaterialPreset: z.string().optional(),
|
||||||
|
|
||||||
|
roofSegmentId: z.string().optional(),
|
||||||
|
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||||
|
rotation: z.number().default(0),
|
||||||
|
|
||||||
|
width: z.number().default(DEFAULT.width),
|
||||||
|
height: z.number().default(DEFAULT.height),
|
||||||
|
frameThickness: z.number().default(DEFAULT.frameThickness),
|
||||||
|
frameDepth: z.number().default(DEFAULT.frameDepth),
|
||||||
|
|
||||||
|
skylightType: SkylightType.default('flat'),
|
||||||
|
|
||||||
|
glassThickness: z.number().default(DEFAULT.glassThickness),
|
||||||
|
|
||||||
|
lanternHeight: z.number().default(DEFAULT.lanternHeight),
|
||||||
|
lanternTopScale: z.number().default(DEFAULT.lanternTopScale),
|
||||||
|
|
||||||
|
openingAngle: z.number().default(DEFAULT.openingAngle),
|
||||||
|
openingSide: SkylightOpeningSide.default(DEFAULT.openingSide),
|
||||||
|
operationState: z.number().min(0).max(1).default(DEFAULT.operationState),
|
||||||
|
motorHousing: z.boolean().default(DEFAULT.motorHousing),
|
||||||
|
|
||||||
|
slideFraction: z.number().min(0).max(1).default(DEFAULT.slideFraction),
|
||||||
|
slideDirection: SkylightSlideDirection.default(DEFAULT.slideDirection),
|
||||||
|
trackWidth: z.number().default(DEFAULT.trackWidth),
|
||||||
|
motorHousingSize: z.number().default(DEFAULT.motorHousingSize),
|
||||||
|
|
||||||
|
curb: z.boolean().default(DEFAULT.curb),
|
||||||
|
curbHeight: z.number().default(DEFAULT.curbHeight),
|
||||||
|
cutoutOffset: z.number().default(DEFAULT.cutoutOffset),
|
||||||
|
|
||||||
|
surfaceNormal: z.tuple([z.number(), z.number(), z.number()]).optional(),
|
||||||
|
}).describe(
|
||||||
|
dedent`
|
||||||
|
Skylight — a framed glass opening hosted on a roof segment. Five
|
||||||
|
type variants drive different geometry (flat / walk-on / lantern /
|
||||||
|
opening / sliding) and animation states.
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
|
||||||
|
export type SkylightNode = z.infer<typeof SkylightNode>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import dedent from 'dedent'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { SolarPanelPresetKey } from '../../solar-panel-presets'
|
||||||
|
import { BaseNode, nodeType, objectId } from '../base'
|
||||||
|
import { MaterialSchema } from '../material'
|
||||||
|
|
||||||
|
export const SolarPanelMaterialRole = z.enum(['frame', 'panel'])
|
||||||
|
export type SolarPanelMaterialRole = z.infer<typeof SolarPanelMaterialRole>
|
||||||
|
|
||||||
|
export const SolarPanelNode = BaseNode.extend({
|
||||||
|
id: objectId('solarpanel'),
|
||||||
|
type: nodeType('solar-panel'),
|
||||||
|
|
||||||
|
// Frame / rail material — aluminum mounting rails.
|
||||||
|
material: MaterialSchema.optional(),
|
||||||
|
materialPreset: z.string().optional(),
|
||||||
|
// Panel surface material — dark photovoltaic glass.
|
||||||
|
panelMaterial: MaterialSchema.optional(),
|
||||||
|
panelMaterialPreset: z.string().optional(),
|
||||||
|
|
||||||
|
// Visual preset that drove panelWidth / panelHeight / frameThickness /
|
||||||
|
// frameDepth. Cleared whenever any of those four is edited manually.
|
||||||
|
panelTypePreset: SolarPanelPresetKey.optional(),
|
||||||
|
|
||||||
|
roofSegmentId: z.string().optional(),
|
||||||
|
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||||
|
rotation: z.number().default(0),
|
||||||
|
|
||||||
|
// Grid layout.
|
||||||
|
rows: z.number().int().min(1).max(20).default(2),
|
||||||
|
columns: z.number().int().min(1).max(20).default(3),
|
||||||
|
|
||||||
|
// Individual panel dimensions (meters). Defaults match the 'residential' preset.
|
||||||
|
panelWidth: z.number().default(1.0),
|
||||||
|
panelHeight: z.number().default(1.65),
|
||||||
|
|
||||||
|
// Gaps between panels.
|
||||||
|
gapX: z.number().default(0.02),
|
||||||
|
gapY: z.number().default(0.02),
|
||||||
|
|
||||||
|
// Mounting type — flush (flat on the slope) or tilted (angled up).
|
||||||
|
mountingType: z.enum(['flush', 'tilted']).default('flush'),
|
||||||
|
tiltAngle: z.number().default(15),
|
||||||
|
standoffHeight: z.number().default(0.05),
|
||||||
|
|
||||||
|
// Frame.
|
||||||
|
frameThickness: z.number().default(0.04),
|
||||||
|
frameDepth: z.number().default(0.04),
|
||||||
|
|
||||||
|
// Surface normal at placement point (segment-local). Captured from a
|
||||||
|
// raycast on the shingle surface during placement / move. Falls back
|
||||||
|
// to the analytical normal in the renderer if absent.
|
||||||
|
surfaceNormal: z.tuple([z.number(), z.number(), z.number()]).optional(),
|
||||||
|
}).describe(
|
||||||
|
dedent`
|
||||||
|
Solar panel array — a grid of photovoltaic panels mounted on a roof
|
||||||
|
segment. Position is segment-local (x along width, z along depth);
|
||||||
|
the Y component is captured from the raycast hit on the shingle
|
||||||
|
surface, with an analytical fallback in the renderer when it's 0.
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
|
||||||
|
export type SolarPanelNode = z.infer<typeof SolarPanelNode>
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import z from 'zod'
|
import z from 'zod'
|
||||||
|
import { BoxVentNode } from './nodes/box-vent'
|
||||||
import { BuildingNode } from './nodes/building'
|
import { BuildingNode } from './nodes/building'
|
||||||
import { CeilingNode } from './nodes/ceiling'
|
import { CeilingNode } from './nodes/ceiling'
|
||||||
|
import { ChimneyNode } from './nodes/chimney'
|
||||||
|
import { DormerNode } from './nodes/dormer'
|
||||||
import { ColumnNode } from './nodes/column'
|
import { ColumnNode } from './nodes/column'
|
||||||
import { DoorNode } from './nodes/door'
|
import { DoorNode } from './nodes/door'
|
||||||
import { ElevatorNode } from './nodes/elevator'
|
import { ElevatorNode } from './nodes/elevator'
|
||||||
@@ -8,10 +11,13 @@ import { FenceNode } from './nodes/fence'
|
|||||||
import { GuideNode } from './nodes/guide'
|
import { GuideNode } from './nodes/guide'
|
||||||
import { ItemNode } from './nodes/item'
|
import { ItemNode } from './nodes/item'
|
||||||
import { LevelNode } from './nodes/level'
|
import { LevelNode } from './nodes/level'
|
||||||
|
import { RidgeVentNode } from './nodes/ridge-vent'
|
||||||
import { RoofNode } from './nodes/roof'
|
import { RoofNode } from './nodes/roof'
|
||||||
import { RoofSegmentNode } from './nodes/roof-segment'
|
import { RoofSegmentNode } from './nodes/roof-segment'
|
||||||
import { ScanNode } from './nodes/scan'
|
import { ScanNode } from './nodes/scan'
|
||||||
import { ShelfNode } from './nodes/shelf'
|
import { ShelfNode } from './nodes/shelf'
|
||||||
|
import { SkylightNode } from './nodes/skylight'
|
||||||
|
import { SolarPanelNode } from './nodes/solar-panel'
|
||||||
import { SiteNode } from './nodes/site'
|
import { SiteNode } from './nodes/site'
|
||||||
import { SlabNode } from './nodes/slab'
|
import { SlabNode } from './nodes/slab'
|
||||||
import { SpawnNode } from './nodes/spawn'
|
import { SpawnNode } from './nodes/spawn'
|
||||||
@@ -43,6 +49,12 @@ export const AnyNode = z.discriminatedUnion('type', [
|
|||||||
SpawnNode,
|
SpawnNode,
|
||||||
WindowNode,
|
WindowNode,
|
||||||
DoorNode,
|
DoorNode,
|
||||||
|
BoxVentNode,
|
||||||
|
RidgeVentNode,
|
||||||
|
ChimneyNode,
|
||||||
|
SolarPanelNode,
|
||||||
|
SkylightNode,
|
||||||
|
DormerNode,
|
||||||
])
|
])
|
||||||
|
|
||||||
export type AnyNode = z.infer<typeof AnyNode>
|
export type AnyNode = z.infer<typeof AnyNode>
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
// Vendor-neutral visual presets. Picking one writes the four `panelWidth`,
|
||||||
|
// `panelHeight`, `frameThickness`, `frameDepth` fields onto a SolarPanelNode.
|
||||||
|
// Manually editing any of those four fields clears the preset back to undefined
|
||||||
|
// ("Custom") so the schema's `panelTypePreset` always matches the dims.
|
||||||
|
|
||||||
|
export const SolarPanelPresetKey = z.enum([
|
||||||
|
'residential',
|
||||||
|
'residential-large',
|
||||||
|
'compact',
|
||||||
|
'frameless',
|
||||||
|
])
|
||||||
|
export type SolarPanelPresetKey = z.infer<typeof SolarPanelPresetKey>
|
||||||
|
|
||||||
|
export type SolarPanelPresetDims = {
|
||||||
|
panelWidth: number
|
||||||
|
panelHeight: number
|
||||||
|
frameThickness: number
|
||||||
|
frameDepth: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SOLAR_PANEL_PRESETS: Record<SolarPanelPresetKey, SolarPanelPresetDims> = {
|
||||||
|
residential: {
|
||||||
|
panelWidth: 1.0,
|
||||||
|
panelHeight: 1.65,
|
||||||
|
frameThickness: 0.04,
|
||||||
|
frameDepth: 0.04,
|
||||||
|
},
|
||||||
|
'residential-large': {
|
||||||
|
panelWidth: 1.0,
|
||||||
|
panelHeight: 2.0,
|
||||||
|
frameThickness: 0.04,
|
||||||
|
frameDepth: 0.04,
|
||||||
|
},
|
||||||
|
compact: {
|
||||||
|
panelWidth: 0.83,
|
||||||
|
panelHeight: 1.0,
|
||||||
|
frameThickness: 0.03,
|
||||||
|
frameDepth: 0.035,
|
||||||
|
},
|
||||||
|
frameless: {
|
||||||
|
panelWidth: 1.0,
|
||||||
|
panelHeight: 1.65,
|
||||||
|
frameThickness: 0.005,
|
||||||
|
frameDepth: 0.025,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SOLAR_PANEL_PRESET_LABELS: Record<SolarPanelPresetKey, string> = {
|
||||||
|
residential: 'Residential',
|
||||||
|
'residential-large': 'Residential Large',
|
||||||
|
compact: 'Compact',
|
||||||
|
frameless: 'Frameless',
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import { beforeEach, describe, expect, test } from 'bun:test'
|
||||||
|
import type { AnyNode, AnyNodeId } from '../../schema/types'
|
||||||
|
import useScene from '../use-scene'
|
||||||
|
|
||||||
|
// bun:test has no DOM — node-actions schedules markDirty via requestAnimationFrame,
|
||||||
|
// so polyfill it as synchronous.
|
||||||
|
type RafFn = (cb: (t: number) => void) => number
|
||||||
|
;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= ((
|
||||||
|
cb: (t: number) => void,
|
||||||
|
) => {
|
||||||
|
cb(0)
|
||||||
|
return 0
|
||||||
|
}) as RafFn
|
||||||
|
;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??=
|
||||||
|
() => {}
|
||||||
|
|
||||||
|
const ROOF_ID = 'roof_test' as AnyNodeId
|
||||||
|
const SEG_A_ID = 'rseg_a' as AnyNodeId
|
||||||
|
const SEG_B_ID = 'rseg_b' as AnyNodeId
|
||||||
|
const VENT_ID = 'bvent_v1' as AnyNodeId
|
||||||
|
|
||||||
|
function makeRoof(): AnyNode {
|
||||||
|
return {
|
||||||
|
id: ROOF_ID,
|
||||||
|
type: 'roof',
|
||||||
|
parentId: null,
|
||||||
|
object: 'node',
|
||||||
|
visible: true,
|
||||||
|
name: '',
|
||||||
|
metadata: {},
|
||||||
|
position: [0, 0, 0],
|
||||||
|
rotation: 0,
|
||||||
|
children: [SEG_A_ID, SEG_B_ID],
|
||||||
|
} as unknown as AnyNode
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSegment(id: AnyNodeId, children: AnyNodeId[] = []): AnyNode {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: 'roof-segment',
|
||||||
|
parentId: ROOF_ID,
|
||||||
|
object: 'node',
|
||||||
|
visible: true,
|
||||||
|
name: '',
|
||||||
|
metadata: {},
|
||||||
|
position: [0, 0, 0],
|
||||||
|
rotation: 0,
|
||||||
|
roofType: 'gable',
|
||||||
|
width: 8,
|
||||||
|
depth: 6,
|
||||||
|
wallHeight: 0.5,
|
||||||
|
pitch: 40,
|
||||||
|
wallThickness: 0.1,
|
||||||
|
deckThickness: 0.1,
|
||||||
|
overhang: 0.3,
|
||||||
|
shingleThickness: 0.05,
|
||||||
|
children,
|
||||||
|
} as unknown as AnyNode
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeVent(parentId: AnyNodeId): AnyNode {
|
||||||
|
return {
|
||||||
|
id: VENT_ID,
|
||||||
|
type: 'box-vent',
|
||||||
|
parentId,
|
||||||
|
roofSegmentId: parentId as string,
|
||||||
|
object: 'node',
|
||||||
|
visible: true,
|
||||||
|
name: '',
|
||||||
|
metadata: {},
|
||||||
|
position: [0, 0, 0],
|
||||||
|
rotation: 0,
|
||||||
|
width: 0.4,
|
||||||
|
depth: 0.4,
|
||||||
|
height: 0.15,
|
||||||
|
hoodOverhang: 0.04,
|
||||||
|
topTaper: 0.4,
|
||||||
|
capHeight: 0.07,
|
||||||
|
capGap: 0,
|
||||||
|
domeCurvature: 0.65,
|
||||||
|
baseInset: 0.06,
|
||||||
|
baseHeight: 0.04,
|
||||||
|
cornerBevel: 0.012,
|
||||||
|
style: 'cap',
|
||||||
|
materialPreset: 'preset-white',
|
||||||
|
} as unknown as AnyNode
|
||||||
|
}
|
||||||
|
|
||||||
|
function childrenOf(id: AnyNodeId): AnyNodeId[] {
|
||||||
|
const seg = useScene.getState().nodes[id] as { children?: AnyNodeId[] } | undefined
|
||||||
|
return (seg?.children ?? []) as AnyNodeId[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function vent(): AnyNode {
|
||||||
|
return useScene.getState().nodes[VENT_ID] as AnyNode
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('node-actions reparent — repeated segment-hopping', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useScene.setState(
|
||||||
|
{
|
||||||
|
nodes: {
|
||||||
|
[ROOF_ID]: makeRoof(),
|
||||||
|
[SEG_A_ID]: makeSegment(SEG_A_ID, [VENT_ID]),
|
||||||
|
[SEG_B_ID]: makeSegment(SEG_B_ID, []),
|
||||||
|
[VENT_ID]: makeVent(SEG_A_ID),
|
||||||
|
},
|
||||||
|
rootNodeIds: [ROOF_ID],
|
||||||
|
} as never,
|
||||||
|
)
|
||||||
|
useScene.temporal.getState().clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('initial state: vent listed once in A.children, absent from B.children', () => {
|
||||||
|
expect(childrenOf(SEG_A_ID)).toEqual([VENT_ID])
|
||||||
|
expect(childrenOf(SEG_B_ID)).toEqual([])
|
||||||
|
expect((vent() as { parentId: AnyNodeId }).parentId).toBe(SEG_A_ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('A→B: auto-reparent moves vent without leaving duplicates', () => {
|
||||||
|
useScene
|
||||||
|
.getState()
|
||||||
|
.updateNode(VENT_ID, { parentId: SEG_B_ID, roofSegmentId: SEG_B_ID } as Partial<AnyNode>)
|
||||||
|
|
||||||
|
expect(childrenOf(SEG_A_ID)).toEqual([])
|
||||||
|
expect(childrenOf(SEG_B_ID)).toEqual([VENT_ID])
|
||||||
|
expect((vent() as { parentId: AnyNodeId }).parentId).toBe(SEG_B_ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('A→B→A→B: three hops leave children lists clean (regression: vent crash)', () => {
|
||||||
|
// Move 1: A → B
|
||||||
|
useScene
|
||||||
|
.getState()
|
||||||
|
.updateNode(VENT_ID, { parentId: SEG_B_ID, roofSegmentId: SEG_B_ID } as Partial<AnyNode>)
|
||||||
|
expect(childrenOf(SEG_A_ID)).toEqual([])
|
||||||
|
expect(childrenOf(SEG_B_ID)).toEqual([VENT_ID])
|
||||||
|
|
||||||
|
// Move 2: B → A
|
||||||
|
useScene
|
||||||
|
.getState()
|
||||||
|
.updateNode(VENT_ID, { parentId: SEG_A_ID, roofSegmentId: SEG_A_ID } as Partial<AnyNode>)
|
||||||
|
expect(childrenOf(SEG_A_ID)).toEqual([VENT_ID])
|
||||||
|
expect(childrenOf(SEG_B_ID)).toEqual([])
|
||||||
|
|
||||||
|
// Move 3: A → B (the move the user reports crashes)
|
||||||
|
useScene
|
||||||
|
.getState()
|
||||||
|
.updateNode(VENT_ID, { parentId: SEG_B_ID, roofSegmentId: SEG_B_ID } as Partial<AnyNode>)
|
||||||
|
expect(childrenOf(SEG_A_ID)).toEqual([])
|
||||||
|
expect(childrenOf(SEG_B_ID)).toEqual([VENT_ID])
|
||||||
|
expect((vent() as { parentId: AnyNodeId }).parentId).toBe(SEG_B_ID)
|
||||||
|
|
||||||
|
// Crucially: the vent must appear in EXACTLY ONE segment's children.
|
||||||
|
// Duplicate listing is what makes the roof renderer mount two
|
||||||
|
// <NodeRenderer key={ventId}> instances and crash the scene-registry
|
||||||
|
// on unmount.
|
||||||
|
const totalListings =
|
||||||
|
childrenOf(SEG_A_ID).filter((id) => id === VENT_ID).length +
|
||||||
|
childrenOf(SEG_B_ID).filter((id) => id === VENT_ID).length
|
||||||
|
expect(totalListings).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
['chimney', 'chmn_x'],
|
||||||
|
['skylight', 'sky_x'],
|
||||||
|
['dormer', 'dorm_x'],
|
||||||
|
['solar-panel', 'sp_x'],
|
||||||
|
['ridge-vent', 'rvent_x'],
|
||||||
|
])(
|
||||||
|
'A→B→A→B for %s: chimney-style auto-reparent leaves children clean',
|
||||||
|
(type, idStr) => {
|
||||||
|
const id = idStr as AnyNodeId
|
||||||
|
useScene.setState(
|
||||||
|
{
|
||||||
|
nodes: {
|
||||||
|
[ROOF_ID]: makeRoof(),
|
||||||
|
[SEG_A_ID]: makeSegment(SEG_A_ID, [id]),
|
||||||
|
[SEG_B_ID]: makeSegment(SEG_B_ID, []),
|
||||||
|
[id]: {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
parentId: SEG_A_ID,
|
||||||
|
roofSegmentId: SEG_A_ID,
|
||||||
|
object: 'node',
|
||||||
|
visible: true,
|
||||||
|
name: '',
|
||||||
|
metadata: {},
|
||||||
|
position: [0, 0, 0],
|
||||||
|
rotation: 0,
|
||||||
|
} as unknown as AnyNode,
|
||||||
|
},
|
||||||
|
rootNodeIds: [ROOF_ID],
|
||||||
|
} as never,
|
||||||
|
)
|
||||||
|
|
||||||
|
const hop = (to: AnyNodeId) =>
|
||||||
|
useScene
|
||||||
|
.getState()
|
||||||
|
.updateNode(id, { parentId: to, roofSegmentId: to } as Partial<AnyNode>)
|
||||||
|
|
||||||
|
hop(SEG_B_ID)
|
||||||
|
hop(SEG_A_ID)
|
||||||
|
hop(SEG_B_ID)
|
||||||
|
|
||||||
|
expect(childrenOf(SEG_A_ID)).toEqual([])
|
||||||
|
expect(childrenOf(SEG_B_ID)).toEqual([id])
|
||||||
|
const node = useScene.getState().nodes[id] as { parentId: AnyNodeId }
|
||||||
|
expect(node.parentId).toBe(SEG_B_ID)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
test('A→B→A→B with redundant manual children edits (vent move-tool pattern, pre-fix)', () => {
|
||||||
|
// Reproduces the box-vent / ridge-vent move-tool order: manual
|
||||||
|
// children edits FIRST, then updateNode({parentId}) which triggers
|
||||||
|
// auto-reparent. The dual handling must not leave the vent listed
|
||||||
|
// in both segments after three hops.
|
||||||
|
const hop = (from: AnyNodeId, to: AnyNodeId) => {
|
||||||
|
const st = useScene.getState()
|
||||||
|
// 1. Manual: filter old children, push to new children — using
|
||||||
|
// the snapshot (st.nodes) captured before any updates.
|
||||||
|
const oldSeg = st.nodes[from] as { children?: AnyNodeId[] } | undefined
|
||||||
|
if (oldSeg) {
|
||||||
|
st.updateNode(from, {
|
||||||
|
children: (oldSeg.children ?? []).filter((id) => id !== VENT_ID),
|
||||||
|
} as Partial<AnyNode>)
|
||||||
|
}
|
||||||
|
const newSeg = st.nodes[to] as { children?: AnyNodeId[] } | undefined
|
||||||
|
if (newSeg && !(newSeg.children ?? []).includes(VENT_ID)) {
|
||||||
|
st.updateNode(to, {
|
||||||
|
children: [...(newSeg.children ?? []), VENT_ID],
|
||||||
|
} as Partial<AnyNode>)
|
||||||
|
}
|
||||||
|
// 2. Then the auto-reparent path via parentId.
|
||||||
|
st.updateNode(VENT_ID, {
|
||||||
|
parentId: to,
|
||||||
|
roofSegmentId: to,
|
||||||
|
} as Partial<AnyNode>)
|
||||||
|
}
|
||||||
|
|
||||||
|
hop(SEG_A_ID, SEG_B_ID)
|
||||||
|
hop(SEG_B_ID, SEG_A_ID)
|
||||||
|
hop(SEG_A_ID, SEG_B_ID)
|
||||||
|
|
||||||
|
expect(childrenOf(SEG_A_ID)).toEqual([])
|
||||||
|
expect(childrenOf(SEG_B_ID)).toEqual([VENT_ID])
|
||||||
|
expect((vent() as { parentId: AnyNodeId }).parentId).toBe(SEG_B_ID)
|
||||||
|
|
||||||
|
const totalListings =
|
||||||
|
childrenOf(SEG_A_ID).filter((id) => id === VENT_ID).length +
|
||||||
|
childrenOf(SEG_B_ID).filter((id) => id === VENT_ID).length
|
||||||
|
expect(totalListings).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -39,6 +39,19 @@ export type WindowAnimationState = {
|
|||||||
persist: boolean
|
persist: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SkylightInteractiveState = {
|
||||||
|
operationState?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SkylightAnimationState = {
|
||||||
|
field: keyof SkylightInteractiveState
|
||||||
|
from: number
|
||||||
|
to: number
|
||||||
|
startedAt: number | null
|
||||||
|
durationMs: number
|
||||||
|
persist: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export type ElevatorPhase = 'idle' | 'closing' | 'moving' | 'opening' | 'open'
|
export type ElevatorPhase = 'idle' | 'closing' | 'moving' | 'opening' | 'open'
|
||||||
|
|
||||||
export type ElevatorInteractiveState = {
|
export type ElevatorInteractiveState = {
|
||||||
@@ -58,6 +71,8 @@ type InteractiveStore = {
|
|||||||
doorAnimations: Record<AnyNodeId, DoorAnimationState>
|
doorAnimations: Record<AnyNodeId, DoorAnimationState>
|
||||||
windows: Record<AnyNodeId, WindowInteractiveState>
|
windows: Record<AnyNodeId, WindowInteractiveState>
|
||||||
windowAnimations: Record<AnyNodeId, WindowAnimationState>
|
windowAnimations: Record<AnyNodeId, WindowAnimationState>
|
||||||
|
skylights: Record<AnyNodeId, SkylightInteractiveState>
|
||||||
|
skylightAnimations: Record<AnyNodeId, SkylightAnimationState>
|
||||||
elevators: Record<AnyNodeId, ElevatorInteractiveState>
|
elevators: Record<AnyNodeId, ElevatorInteractiveState>
|
||||||
|
|
||||||
/** Initialize a node's interactive state from its asset definition (idempotent) */
|
/** Initialize a node's interactive state from its asset definition (idempotent) */
|
||||||
@@ -93,6 +108,18 @@ type InteractiveStore = {
|
|||||||
/** Cancel a queued window animation */
|
/** Cancel a queued window animation */
|
||||||
cancelWindowAnimation: (windowId: AnyNodeId) => void
|
cancelWindowAnimation: (windowId: AnyNodeId) => void
|
||||||
|
|
||||||
|
/** Set transient skylight open state without committing it to the scene node */
|
||||||
|
setSkylightOpenState: (skylightId: AnyNodeId, value: SkylightInteractiveState) => void
|
||||||
|
|
||||||
|
/** Clear transient skylight open state */
|
||||||
|
removeSkylightOpenState: (skylightId: AnyNodeId) => void
|
||||||
|
|
||||||
|
/** Queue a skylight animation for the viewer frame loop */
|
||||||
|
startSkylightAnimation: (skylightId: AnyNodeId, value: SkylightAnimationState) => void
|
||||||
|
|
||||||
|
/** Cancel a queued skylight animation */
|
||||||
|
cancelSkylightAnimation: (skylightId: AnyNodeId) => void
|
||||||
|
|
||||||
/** Initialize an elevator's runtime state from its default served level. */
|
/** Initialize an elevator's runtime state from its default served level. */
|
||||||
initElevator: (elevatorId: AnyNodeId, levelId: AnyNodeId, carY: number) => void
|
initElevator: (elevatorId: AnyNodeId, levelId: AnyNodeId, carY: number) => void
|
||||||
|
|
||||||
@@ -122,6 +149,8 @@ export const useInteractive = create<InteractiveStore>((set, get) => ({
|
|||||||
doorAnimations: {},
|
doorAnimations: {},
|
||||||
windows: {},
|
windows: {},
|
||||||
windowAnimations: {},
|
windowAnimations: {},
|
||||||
|
skylights: {},
|
||||||
|
skylightAnimations: {},
|
||||||
elevators: {},
|
elevators: {},
|
||||||
|
|
||||||
initItem: (itemId, interactive) => {
|
initItem: (itemId, interactive) => {
|
||||||
@@ -228,6 +257,41 @@ export const useInteractive = create<InteractiveStore>((set, get) => ({
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setSkylightOpenState: (skylightId, value) => {
|
||||||
|
set((state) => ({
|
||||||
|
skylights: {
|
||||||
|
...state.skylights,
|
||||||
|
[skylightId]: {
|
||||||
|
...state.skylights[skylightId],
|
||||||
|
...value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
removeSkylightOpenState: (skylightId) => {
|
||||||
|
set((state) => {
|
||||||
|
const { [skylightId]: _, ...rest } = state.skylights
|
||||||
|
return { skylights: rest }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
startSkylightAnimation: (skylightId, value) => {
|
||||||
|
set((state) => ({
|
||||||
|
skylightAnimations: {
|
||||||
|
...state.skylightAnimations,
|
||||||
|
[skylightId]: value,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelSkylightAnimation: (skylightId) => {
|
||||||
|
set((state) => {
|
||||||
|
const { [skylightId]: _, ...rest } = state.skylightAnimations
|
||||||
|
return { skylightAnimations: rest }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
initElevator: (elevatorId, levelId, carY) => {
|
initElevator: (elevatorId, levelId, carY) => {
|
||||||
if (get().elevators[elevatorId]) return
|
if (get().elevators[elevatorId]) return
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import { BuildingNode } from '../schema'
|
|||||||
import type { Collection, CollectionId } from '../schema/collections'
|
import type { Collection, CollectionId } from '../schema/collections'
|
||||||
import { generateCollectionId } from '../schema/collections'
|
import { generateCollectionId } from '../schema/collections'
|
||||||
import { LevelNode } from '../schema/nodes/level'
|
import { LevelNode } from '../schema/nodes/level'
|
||||||
|
import {
|
||||||
|
getPitchFromActiveRoofHeight,
|
||||||
|
type RoofType,
|
||||||
|
} from '../schema/nodes/roof-segment'
|
||||||
import { SiteNode } from '../schema/nodes/site'
|
import { SiteNode } from '../schema/nodes/site'
|
||||||
import { StairNode as StairNodeSchema } from '../schema/nodes/stair'
|
import { StairNode as StairNodeSchema } from '../schema/nodes/stair'
|
||||||
import { StairSegmentNode as StairSegmentNodeSchema } from '../schema/nodes/stair-segment'
|
import { StairSegmentNode as StairSegmentNodeSchema } from '../schema/nodes/stair-segment'
|
||||||
@@ -297,6 +301,9 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
|
|||||||
const suffix = id.includes('_') ? id.split('_')[1] : Math.random().toString(36).slice(2)
|
const suffix = id.includes('_') ? id.split('_')[1] : Math.random().toString(36).slice(2)
|
||||||
const segmentId = `rseg_${suffix}`
|
const segmentId = `rseg_${suffix}`
|
||||||
|
|
||||||
|
const segWidth = oldRoof.length ?? 8
|
||||||
|
const segDepth = (oldRoof.leftWidth ?? 2.2) + (oldRoof.rightWidth ?? 2.2)
|
||||||
|
const legacyRoofHeight = oldRoof.height ?? 2.5
|
||||||
const segment = {
|
const segment = {
|
||||||
object: 'node',
|
object: 'node',
|
||||||
id: segmentId,
|
id: segmentId,
|
||||||
@@ -307,10 +314,15 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
|
|||||||
position: [0, 0, 0],
|
position: [0, 0, 0],
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
roofType: 'gable',
|
roofType: 'gable',
|
||||||
width: oldRoof.length ?? 8,
|
width: segWidth,
|
||||||
depth: (oldRoof.leftWidth ?? 2.2) + (oldRoof.rightWidth ?? 2.2),
|
depth: segDepth,
|
||||||
wallHeight: 0,
|
wallHeight: 0,
|
||||||
roofHeight: oldRoof.height ?? 2.5,
|
pitch: getPitchFromActiveRoofHeight({
|
||||||
|
roofType: 'gable',
|
||||||
|
width: segWidth,
|
||||||
|
depth: segDepth,
|
||||||
|
roofHeight: legacyRoofHeight,
|
||||||
|
}),
|
||||||
wallThickness: 0.1,
|
wallThickness: 0.1,
|
||||||
deckThickness: 0.1,
|
deckThickness: 0.1,
|
||||||
overhang: 0.3,
|
overhang: 0.3,
|
||||||
@@ -324,6 +336,26 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2b. roof-segment: legacy roofHeight → pitch.
|
||||||
|
// Saved scenes wrote `roofHeight` in metres; the schema now stores
|
||||||
|
// `pitch` in degrees. Convert once and drop the old field so future
|
||||||
|
// loads skip this branch.
|
||||||
|
if (node.type === 'roof-segment' && 'roofHeight' in node && !('pitch' in node)) {
|
||||||
|
const { roofHeight, ...rest } = node
|
||||||
|
const width = typeof node.width === 'number' ? node.width : 8
|
||||||
|
const depth = typeof node.depth === 'number' ? node.depth : 6
|
||||||
|
const roofType = (typeof node.roofType === 'string' ? node.roofType : 'gable') as RoofType
|
||||||
|
patchedNodes[id] = {
|
||||||
|
...rest,
|
||||||
|
pitch: getPitchFromActiveRoofHeight({
|
||||||
|
roofType,
|
||||||
|
width,
|
||||||
|
depth,
|
||||||
|
roofHeight: typeof roofHeight === 'number' ? roofHeight : 2.5,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (node.type === 'stair') {
|
if (node.type === 'stair') {
|
||||||
const normalized = normalizeStairNode(migrateStairSurfaceMaterials(node))
|
const normalized = normalizeStairNode(migrateStairSurfaceMaterials(node))
|
||||||
if (normalized) {
|
if (normalized) {
|
||||||
@@ -352,6 +384,18 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
|
|||||||
patchedNodes[id] = { ...node, children: [] }
|
patchedNodes[id] = { ...node, children: [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Roof-segment hosting was added in this migration cycle (the same
|
||||||
|
// pattern as shelf above). Older segments saved before the schema
|
||||||
|
// gained `children` need the field initialised so
|
||||||
|
// `createNode(chimney, segmentId)` finds an array to append to —
|
||||||
|
// without this every "Add Element" click on the roof panel results
|
||||||
|
// in an orphaned accessory (parented in scene state but never
|
||||||
|
// appended to `seg.children`, so the renderer's recursive
|
||||||
|
// `<NodeRenderer>` mount never sees it).
|
||||||
|
if (node.type === 'roof-segment' && !Array.isArray((node as { children?: unknown }).children)) {
|
||||||
|
patchedNodes[id] = { ...node, children: [] } as AnyNode
|
||||||
|
}
|
||||||
|
|
||||||
if (node.type === 'roof') {
|
if (node.type === 'roof') {
|
||||||
patchedNodes[id] = migrateRoofSurfaceMaterials(patchedNodes[id])
|
patchedNodes[id] = migrateRoofSurfaceMaterials(patchedNodes[id])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -352,6 +352,15 @@ export function FloatingActionMenu() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Duplicate children for stair nodes
|
// Duplicate children for stair nodes
|
||||||
|
} else if (duplicate.type === 'chimney' || duplicate.type === 'dormer') {
|
||||||
|
// Chimney & dormer use pure drag-to-place: NO node is
|
||||||
|
// inserted into the scene until the user clicks a roof
|
||||||
|
// segment. The `setMovingNode` call below hands the clone
|
||||||
|
// (with `metadata.isNew = true` + no id) to
|
||||||
|
// `MoveChimneyTool` / `MoveDormerTool`, which call
|
||||||
|
// `createNode` on the click that commits the placement.
|
||||||
|
// Skipping the auto-create avoids the "duplicate appears at
|
||||||
|
// +1 offset before drag" UX the other registry kinds use.
|
||||||
} else if (nodeRegistry.has(duplicate.type)) {
|
} else if (nodeRegistry.has(duplicate.type)) {
|
||||||
// Registry-driven kinds: offset the position slightly so the
|
// Registry-driven kinds: offset the position slightly so the
|
||||||
// duplicate doesn't overlap exactly, then create + hand to the
|
// duplicate doesn't overlap exactly, then create + hand to the
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import {
|
import {
|
||||||
type AnyNode,
|
type AnyNode,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
|
type BoxVentNode,
|
||||||
type BuildingNode,
|
type BuildingNode,
|
||||||
type CeilingNode,
|
type CeilingNode,
|
||||||
type ColumnNode,
|
type ColumnNode,
|
||||||
emitter,
|
emitter,
|
||||||
type FenceNode,
|
type FenceNode,
|
||||||
|
getEffectiveRoofSurfaceMaterial,
|
||||||
|
getEffectiveSegmentSurfaceMaterial,
|
||||||
getMaterialPresetByRef,
|
getMaterialPresetByRef,
|
||||||
getSelectableKinds,
|
getSelectableKinds,
|
||||||
type ItemNode,
|
type ItemNode,
|
||||||
@@ -15,8 +18,10 @@ import {
|
|||||||
type RoofEvent,
|
type RoofEvent,
|
||||||
type RoofNode,
|
type RoofNode,
|
||||||
type RoofSegmentEvent,
|
type RoofSegmentEvent,
|
||||||
|
type RoofSegmentNode,
|
||||||
resolveLevelId,
|
resolveLevelId,
|
||||||
resolveMaterial,
|
resolveMaterial,
|
||||||
|
type RidgeVentNode,
|
||||||
type ShelfNode,
|
type ShelfNode,
|
||||||
type SlabNode,
|
type SlabNode,
|
||||||
type StairEvent,
|
type StairEvent,
|
||||||
@@ -44,6 +49,8 @@ import { useCallback, useEffect, useRef } from 'react'
|
|||||||
import { type BufferGeometry, Color, type Material, type Mesh, type Object3D } from 'three'
|
import { type BufferGeometry, Color, type Material, type Mesh, type Object3D } from 'three'
|
||||||
import {
|
import {
|
||||||
type ActivePaintMaterial,
|
type ActivePaintMaterial,
|
||||||
|
buildRoofSegmentSurfaceMaterialPatch,
|
||||||
|
buildRoofSurfaceMaterialUpdates,
|
||||||
buildRoofSurfaceMaterialPatch,
|
buildRoofSurfaceMaterialPatch,
|
||||||
buildSingleSurfaceMaterialPatch,
|
buildSingleSurfaceMaterialPatch,
|
||||||
buildStairSurfaceMaterialPatch,
|
buildStairSurfaceMaterialPatch,
|
||||||
@@ -125,33 +132,6 @@ export const resolveBuildingId = (
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveWallMaterialTarget(event: WallEvent): WallSurfaceSide | null {
|
|
||||||
const materialIndex = getIntersectionMaterialIndex(getEventObject(event), event.faceIndex)
|
|
||||||
if (materialIndex === 1) return 'interior'
|
|
||||||
if (materialIndex === 2) return 'exterior'
|
|
||||||
|
|
||||||
const normalZ = event.normal?.[2]
|
|
||||||
const localZ = event.localPosition[2]
|
|
||||||
const thickness = event.node.thickness ?? 0.1
|
|
||||||
|
|
||||||
if (
|
|
||||||
normalZ === undefined ||
|
|
||||||
Math.abs(normalZ) < 0.65 ||
|
|
||||||
Math.abs(localZ) < Math.max(thickness * 0.2, 0.01)
|
|
||||||
) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const hitFace = localZ >= 0 ? 'front' : 'back'
|
|
||||||
const semantic = hitFace === 'front' ? event.node.frontSide : event.node.backSide
|
|
||||||
|
|
||||||
if (semantic === 'interior' || semantic === 'exterior') {
|
|
||||||
return semantic
|
|
||||||
}
|
|
||||||
|
|
||||||
return hitFace === 'front' ? 'interior' : 'exterior'
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveStairMaterialTarget(
|
function resolveStairMaterialTarget(
|
||||||
event: StairEvent | StairSegmentEvent,
|
event: StairEvent | StairSegmentEvent,
|
||||||
): StairSurfaceMaterialRole | null {
|
): StairSurfaceMaterialRole | null {
|
||||||
@@ -261,22 +241,6 @@ function getSingleSurfacePreviewMaterial(material: ActivePaintMaterial): Materia
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyWallPaintPreview(
|
|
||||||
node: WallNode,
|
|
||||||
role: WallSurfaceSide,
|
|
||||||
material: ActivePaintMaterial,
|
|
||||||
): PaintPreviewCleanup | null {
|
|
||||||
const mesh = getRegisteredMesh(node.id)
|
|
||||||
if (!mesh) return null
|
|
||||||
|
|
||||||
const previewNode = {
|
|
||||||
...node,
|
|
||||||
...buildWallSurfaceMaterialPatch(node, role, material.material, material.materialPreset),
|
|
||||||
}
|
|
||||||
|
|
||||||
return previewMeshMaterial(mesh, getVisibleWallMaterials(previewNode))
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyRoofPaintPreview(
|
function applyRoofPaintPreview(
|
||||||
node: RoofNode,
|
node: RoofNode,
|
||||||
role: 'top' | 'edge' | 'wall',
|
role: 'top' | 'edge' | 'wall',
|
||||||
@@ -296,6 +260,54 @@ function applyRoofPaintPreview(
|
|||||||
return previewMeshMaterial(mesh, previewMaterial)
|
return previewMeshMaterial(mesh, previewMaterial)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyRoofSegmentPaintPreview(
|
||||||
|
node: RoofSegmentNode,
|
||||||
|
parent: RoofNode | null,
|
||||||
|
role: 'top' | 'edge' | 'wall',
|
||||||
|
material: ActivePaintMaterial,
|
||||||
|
): PaintPreviewCleanup | null {
|
||||||
|
const mesh = getRegisteredMesh(node.id)
|
||||||
|
if (!mesh) return null
|
||||||
|
|
||||||
|
// Synthesise the segment node as if the paint had committed, then build
|
||||||
|
// the same 4-slot array the renderer would. Mirrors getRoofMaterialArray
|
||||||
|
// layout (slot 0 ← edge, 1 ← wall, 2 ← wall, 3 ← top) so the preview
|
||||||
|
// material lands on the matching CSG groups.
|
||||||
|
const previewNode: RoofSegmentNode = {
|
||||||
|
...node,
|
||||||
|
...buildRoofSegmentSurfaceMaterialPatch(
|
||||||
|
node,
|
||||||
|
role,
|
||||||
|
material.material,
|
||||||
|
material.materialPreset,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
const resolveSlot = (r: 'top' | 'edge' | 'wall'): Material | null => {
|
||||||
|
const parentSpec = parent ? getEffectiveRoofSurfaceMaterial(parent, r) : undefined
|
||||||
|
const spec = getEffectiveSegmentSurfaceMaterial(previewNode, r, parentSpec)
|
||||||
|
if (typeof spec.materialPreset === 'string') {
|
||||||
|
const resolved = createMaterialFromPresetRef(spec.materialPreset)
|
||||||
|
if (resolved) return resolved
|
||||||
|
}
|
||||||
|
if (spec.material !== undefined) return createMaterial(spec.material)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const edge = resolveSlot('edge')
|
||||||
|
const wall = resolveSlot('wall')
|
||||||
|
const top = resolveSlot('top')
|
||||||
|
if (!(edge || wall || top)) return null
|
||||||
|
const fallback = parent ? getRoofMaterialArray(parent) : null
|
||||||
|
const fb = (n: number) => fallback?.[n] ?? null
|
||||||
|
const arr: Material[] = [
|
||||||
|
edge ?? wall ?? top ?? fb(0)!,
|
||||||
|
wall ?? edge ?? top ?? fb(1)!,
|
||||||
|
wall ?? edge ?? top ?? fb(2)!,
|
||||||
|
top ?? wall ?? edge ?? fb(3)!,
|
||||||
|
]
|
||||||
|
if (arr.some((m) => !m)) return null
|
||||||
|
return previewMeshMaterial(mesh, arr)
|
||||||
|
}
|
||||||
|
|
||||||
function applyStairPaintPreview(
|
function applyStairPaintPreview(
|
||||||
node: StairNode,
|
node: StairNode,
|
||||||
role: StairSurfaceMaterialRole,
|
role: StairSurfaceMaterialRole,
|
||||||
@@ -342,7 +354,14 @@ function applyStairPaintPreview(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applySingleSurfacePaintPreview(
|
function applySingleSurfacePaintPreview(
|
||||||
node: FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode,
|
node:
|
||||||
|
| FenceNode
|
||||||
|
| ColumnNode
|
||||||
|
| SlabNode
|
||||||
|
| CeilingNode
|
||||||
|
| ShelfNode
|
||||||
|
| BoxVentNode
|
||||||
|
| RidgeVentNode,
|
||||||
material: ActivePaintMaterial,
|
material: ActivePaintMaterial,
|
||||||
): PaintPreviewCleanup | null {
|
): PaintPreviewCleanup | null {
|
||||||
if (node.type === 'ceiling') {
|
if (node.type === 'ceiling') {
|
||||||
@@ -410,9 +429,10 @@ function applySingleSurfacePaintPreview(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.type === 'shelf') {
|
if (node.type === 'shelf' || node.type === 'box-vent' || node.type === 'ridge-vent') {
|
||||||
// Shelf is a registered Group, not a Mesh. Traverse children and
|
// These kinds register a `<group>` (not a Mesh) with `useRegistry`,
|
||||||
// preview-swap every child mesh — same approach `column` uses.
|
// so we walk the subtree and preview-swap every child mesh — same
|
||||||
|
// approach `column` uses.
|
||||||
if (!registeredObject) return null
|
if (!registeredObject) return null
|
||||||
const restores: PaintPreviewCleanup[] = []
|
const restores: PaintPreviewCleanup[] = []
|
||||||
registeredObject.traverse((object) => {
|
registeredObject.traverse((object) => {
|
||||||
@@ -451,6 +471,11 @@ function applySingleSurfacePaintPreview(
|
|||||||
return previewMeshMaterial(mesh, previewMaterial)
|
return previewMeshMaterial(mesh, previewMaterial)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Chimney + dormer paint dispatch lives on their NodeDefinition's
|
||||||
|
// `capabilities.paint` (see packages/nodes/src/{chimney,dormer}/
|
||||||
|
// paint.ts). The generic registry-driven arm in this file consults
|
||||||
|
// those entries — no per-kind helpers needed here.
|
||||||
|
|
||||||
function setSelectedMaterialTargetForNode(node: AnyNode, role: MaterialTargetRole | null) {
|
function setSelectedMaterialTargetForNode(node: AnyNode, role: MaterialTargetRole | null) {
|
||||||
if (!role) {
|
if (!role) {
|
||||||
const currentTarget = useEditor.getState().selectedMaterialTarget
|
const currentTarget = useEditor.getState().selectedMaterialTarget
|
||||||
@@ -833,40 +858,61 @@ export const SelectionManager = () => {
|
|||||||
|
|
||||||
if (!isNodeInCurrentLevel(node)) return null
|
if (!isNodeInCurrentLevel(node)) return null
|
||||||
|
|
||||||
if (node.type === 'wall') {
|
// Registry-driven paint dispatch — kinds that declare
|
||||||
const role = resolveWallMaterialTarget(event as WallEvent)
|
// `capabilities.paint` route hover / click / preview through
|
||||||
|
// their definition. Wall, chimney, and dormer use this; legacy
|
||||||
|
// roof / stair / single-surface arms below stay until they
|
||||||
|
// migrate too.
|
||||||
|
const paintCap = nodeRegistry.get(node.type)?.capabilities?.paint
|
||||||
|
if (paintCap) {
|
||||||
|
const materialIndex = getIntersectionMaterialIndex(getEventObject(event), event.faceIndex)
|
||||||
|
const role = paintCap.resolveRole({
|
||||||
|
node,
|
||||||
|
materialIndex: materialIndex ?? null,
|
||||||
|
normal: event.normal,
|
||||||
|
localPosition: event.localPosition as readonly [number, number, number] | undefined,
|
||||||
|
hitObjectName: event.nativeEvent.object?.name,
|
||||||
|
})
|
||||||
const compatible = role !== null && hasActivePaintMaterial(activePaintMaterial)
|
const compatible = role !== null && hasActivePaintMaterial(activePaintMaterial)
|
||||||
return {
|
return {
|
||||||
key: `wall:${node.id}:${role ?? 'unsupported'}`,
|
key: `${node.type}:${node.id}:${role ?? 'unsupported'}`,
|
||||||
hoveredId: node.id as AnyNodeId,
|
hoveredId: node.id as AnyNodeId,
|
||||||
hoverMode:
|
hoverMode: compatible ? 'paint-ready' : 'paint-disabled',
|
||||||
compatible && hasActivePaintMaterial(activePaintMaterial) && role
|
|
||||||
? 'paint-ready'
|
|
||||||
: 'paint-disabled',
|
|
||||||
apply:
|
apply:
|
||||||
compatible && hasActivePaintMaterial(activePaintMaterial)
|
compatible && role
|
||||||
? () => {
|
? () => {
|
||||||
useScene
|
useScene
|
||||||
.getState()
|
.getState()
|
||||||
.updateNode(
|
.updateNode(
|
||||||
node.id as AnyNodeId,
|
node.id as AnyNodeId,
|
||||||
buildWallSurfaceMaterialPatch(
|
paintCap.buildPatch({
|
||||||
node as WallNode,
|
node,
|
||||||
role!,
|
role,
|
||||||
activePaintMaterial.material,
|
material: activePaintMaterial.material,
|
||||||
activePaintMaterial.materialPreset,
|
materialPreset: activePaintMaterial.materialPreset,
|
||||||
),
|
}) as Partial<AnyNode>,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
preview:
|
preview:
|
||||||
compatible && hasActivePaintMaterial(activePaintMaterial) && role
|
compatible && role
|
||||||
? () => applyWallPaintPreview(node as WallNode, role, activePaintMaterial)
|
? () => {
|
||||||
|
const root = getRegisteredNodeObject(node.id)
|
||||||
|
if (!root) return null
|
||||||
|
return paintCap.applyPreview({
|
||||||
|
node,
|
||||||
|
role,
|
||||||
|
material: activePaintMaterial.material,
|
||||||
|
materialPreset: activePaintMaterial.materialPreset,
|
||||||
|
root,
|
||||||
|
})
|
||||||
|
}
|
||||||
: () => previewCursor('not-allowed'),
|
: () => previewCursor('not-allowed'),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.type === 'roof' || node.type === 'roof-segment') {
|
if (node.type === 'roof' || node.type === 'roof-segment') {
|
||||||
|
const isSegmentHit = node.type === 'roof-segment'
|
||||||
const roofNode =
|
const roofNode =
|
||||||
node.type === 'roof'
|
node.type === 'roof'
|
||||||
? node
|
? node
|
||||||
@@ -877,9 +923,16 @@ export const SelectionManager = () => {
|
|||||||
|
|
||||||
const role = resolveRoofMaterialTarget(event as RoofEvent | RoofSegmentEvent)
|
const role = resolveRoofMaterialTarget(event as RoofEvent | RoofSegmentEvent)
|
||||||
const compatible = role !== null && hasActivePaintMaterial(activePaintMaterial)
|
const compatible = role !== null && hasActivePaintMaterial(activePaintMaterial)
|
||||||
|
// Painting directly on a segment (only possible in segment edit
|
||||||
|
// mode, where the per-segment mesh is visible) writes to the
|
||||||
|
// segment's own role-specific fields. Painting the merged shell
|
||||||
|
// — or a roof node directly — keeps fanning to the parent roof.
|
||||||
|
const segmentTarget = isSegmentHit ? (node as RoofSegmentNode) : null
|
||||||
return {
|
return {
|
||||||
key: `roof:${roofNode.id}:${role ?? 'unsupported'}`,
|
key: `${segmentTarget ? 'roof-segment' : 'roof'}:${
|
||||||
hoveredId: roofNode.id as AnyNodeId,
|
segmentTarget ? segmentTarget.id : roofNode.id
|
||||||
|
}:${role ?? 'unsupported'}`,
|
||||||
|
hoveredId: (segmentTarget ? segmentTarget.id : roofNode.id) as AnyNodeId,
|
||||||
hoverMode:
|
hoverMode:
|
||||||
compatible && hasActivePaintMaterial(activePaintMaterial) && role
|
compatible && hasActivePaintMaterial(activePaintMaterial) && role
|
||||||
? 'paint-ready'
|
? 'paint-ready'
|
||||||
@@ -887,22 +940,41 @@ export const SelectionManager = () => {
|
|||||||
apply:
|
apply:
|
||||||
compatible && hasActivePaintMaterial(activePaintMaterial)
|
compatible && hasActivePaintMaterial(activePaintMaterial)
|
||||||
? () => {
|
? () => {
|
||||||
useScene
|
const sceneState = useScene.getState()
|
||||||
.getState()
|
if (segmentTarget) {
|
||||||
.updateNode(
|
sceneState.updateNode(
|
||||||
roofNode.id as AnyNodeId,
|
segmentTarget.id as AnyNodeId,
|
||||||
buildRoofSurfaceMaterialPatch(
|
buildRoofSegmentSurfaceMaterialPatch(
|
||||||
|
segmentTarget,
|
||||||
|
role!,
|
||||||
|
activePaintMaterial.material,
|
||||||
|
activePaintMaterial.materialPreset,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
sceneState.updateNodes(
|
||||||
|
buildRoofSurfaceMaterialUpdates(
|
||||||
|
sceneState.nodes,
|
||||||
roofNode as RoofNode,
|
roofNode as RoofNode,
|
||||||
role!,
|
role!,
|
||||||
activePaintMaterial.material,
|
activePaintMaterial.material,
|
||||||
activePaintMaterial.materialPreset,
|
activePaintMaterial.materialPreset,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
preview:
|
preview:
|
||||||
compatible && hasActivePaintMaterial(activePaintMaterial) && role
|
compatible && hasActivePaintMaterial(activePaintMaterial) && role
|
||||||
? () => applyRoofPaintPreview(roofNode as RoofNode, role, activePaintMaterial)
|
? () =>
|
||||||
|
segmentTarget
|
||||||
|
? applyRoofSegmentPaintPreview(
|
||||||
|
segmentTarget,
|
||||||
|
roofNode as RoofNode,
|
||||||
|
role,
|
||||||
|
activePaintMaterial,
|
||||||
|
)
|
||||||
|
: applyRoofPaintPreview(roofNode as RoofNode, role, activePaintMaterial)
|
||||||
: () => previewCursor('not-allowed'),
|
: () => previewCursor('not-allowed'),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -948,12 +1020,19 @@ export const SelectionManager = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Registry-driven paint dispatch handled at the top of this
|
||||||
|
// function — kinds declaring `capabilities.paint` return there
|
||||||
|
// before any of the legacy roof / stair / single-surface arms
|
||||||
|
// below run.
|
||||||
|
|
||||||
if (
|
if (
|
||||||
node.type === 'fence' ||
|
node.type === 'fence' ||
|
||||||
node.type === 'column' ||
|
node.type === 'column' ||
|
||||||
node.type === 'slab' ||
|
node.type === 'slab' ||
|
||||||
node.type === 'ceiling' ||
|
node.type === 'ceiling' ||
|
||||||
node.type === 'shelf'
|
node.type === 'shelf' ||
|
||||||
|
node.type === 'box-vent' ||
|
||||||
|
node.type === 'ridge-vent'
|
||||||
) {
|
) {
|
||||||
const compatible = hasActivePaintMaterial(activePaintMaterial)
|
const compatible = hasActivePaintMaterial(activePaintMaterial)
|
||||||
|
|
||||||
@@ -968,7 +1047,13 @@ export const SelectionManager = () => {
|
|||||||
.updateNode(
|
.updateNode(
|
||||||
node.id as AnyNodeId,
|
node.id as AnyNodeId,
|
||||||
buildSingleSurfaceMaterialPatch<
|
buildSingleSurfaceMaterialPatch<
|
||||||
FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode
|
| FenceNode
|
||||||
|
| ColumnNode
|
||||||
|
| SlabNode
|
||||||
|
| CeilingNode
|
||||||
|
| ShelfNode
|
||||||
|
| BoxVentNode
|
||||||
|
| RidgeVentNode
|
||||||
>(activePaintMaterial.material, activePaintMaterial.materialPreset),
|
>(activePaintMaterial.material, activePaintMaterial.materialPreset),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -976,7 +1061,14 @@ export const SelectionManager = () => {
|
|||||||
preview: compatible
|
preview: compatible
|
||||||
? () =>
|
? () =>
|
||||||
applySingleSurfacePaintPreview(
|
applySingleSurfacePaintPreview(
|
||||||
node as FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode,
|
node as
|
||||||
|
| FenceNode
|
||||||
|
| ColumnNode
|
||||||
|
| SlabNode
|
||||||
|
| CeilingNode
|
||||||
|
| ShelfNode
|
||||||
|
| BoxVentNode
|
||||||
|
| RidgeVentNode,
|
||||||
activePaintMaterial,
|
activePaintMaterial,
|
||||||
)
|
)
|
||||||
: () => previewCursor('not-allowed'),
|
: () => previewCursor('not-allowed'),
|
||||||
@@ -1181,15 +1273,33 @@ export const SelectionManager = () => {
|
|||||||
|
|
||||||
let nextMaterialTargetHandled = false
|
let nextMaterialTargetHandled = false
|
||||||
|
|
||||||
if (node.type === 'wall' && nodeToSelect.type === 'wall') {
|
// Registry-driven paint-target resolve on click. Kinds with
|
||||||
setSelectedMaterialTargetForNode(
|
// `capabilities.paint` route through this entry — wall,
|
||||||
nodeToSelect,
|
// chimney, dormer use it today. The legacy stair / roof /
|
||||||
resolveWallMaterialTarget(event as WallEvent),
|
// single-surface arms below stay until they migrate too.
|
||||||
)
|
if (nodeToSelect.type === node.type) {
|
||||||
nextMaterialTargetHandled = true
|
const paintCap = nodeRegistry.get(node.type)?.capabilities?.paint
|
||||||
|
if (paintCap) {
|
||||||
|
const materialIndex = getIntersectionMaterialIndex(
|
||||||
|
getEventObject(event),
|
||||||
|
event.faceIndex,
|
||||||
|
)
|
||||||
|
const role = paintCap.resolveRole({
|
||||||
|
node,
|
||||||
|
materialIndex: materialIndex ?? null,
|
||||||
|
normal: event.normal,
|
||||||
|
localPosition: event.localPosition as readonly [number, number, number] | undefined,
|
||||||
|
hitObjectName: event.nativeEvent.object?.name,
|
||||||
|
})
|
||||||
|
if (role) {
|
||||||
|
setSelectedMaterialTargetForNode(nodeToSelect, role as MaterialTargetRole)
|
||||||
|
nextMaterialTargetHandled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
!nextMaterialTargetHandled &&
|
||||||
(node.type === 'stair' || node.type === 'stair-segment') &&
|
(node.type === 'stair' || node.type === 'stair-segment') &&
|
||||||
nodeToSelect.type === 'stair'
|
nodeToSelect.type === 'stair'
|
||||||
) {
|
) {
|
||||||
@@ -1201,6 +1311,7 @@ export const SelectionManager = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
!nextMaterialTargetHandled &&
|
||||||
(node.type === 'roof' || node.type === 'roof-segment') &&
|
(node.type === 'roof' || node.type === 'roof-segment') &&
|
||||||
nodeToSelect.type === 'roof'
|
nodeToSelect.type === 'roof'
|
||||||
) {
|
) {
|
||||||
@@ -1212,6 +1323,7 @@ export const SelectionManager = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
!nextMaterialTargetHandled &&
|
||||||
(node.type === 'fence' ||
|
(node.type === 'fence' ||
|
||||||
node.type === 'slab' ||
|
node.type === 'slab' ||
|
||||||
node.type === 'ceiling' ||
|
node.type === 'ceiling' ||
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { type AssetInput, isObject } from '@pascal-app/core'
|
import { type AssetInput, isObject } from '@pascal-app/core'
|
||||||
|
import { Euler, Matrix3, type Matrix4, Quaternion, Vector3 } from 'three'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
|
|
||||||
function getGridSnapStep(): number {
|
function getGridSnapStep(): number {
|
||||||
@@ -118,3 +119,28 @@ export function stripTransient(meta: any): any {
|
|||||||
const { isTransient, ...rest } = meta as Record<string, any>
|
const { isTransient, ...rest } = meta as Record<string, any>
|
||||||
return rest
|
return rest
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const _up = new Vector3(0, 1, 0)
|
||||||
|
const _normal = new Vector3()
|
||||||
|
const _quat = new Quaternion()
|
||||||
|
const _euler = new Euler()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute euler rotation that tilts an item so its local +Y aligns with a
|
||||||
|
* roof surface normal. The normal is in the hit mesh's local space and is
|
||||||
|
* transformed to world space via the mesh's matrixWorld.
|
||||||
|
*/
|
||||||
|
export function calculateRoofRotation(
|
||||||
|
normal: [number, number, number] | undefined,
|
||||||
|
objectMatrixWorld: Matrix4,
|
||||||
|
): [number, number, number] {
|
||||||
|
if (!normal) return [0, 0, 0]
|
||||||
|
|
||||||
|
_normal.set(normal[0], normal[1], normal[2])
|
||||||
|
_normal.applyNormalMatrix(new Matrix3().getNormalMatrix(objectMatrixWorld)).normalize()
|
||||||
|
|
||||||
|
_quat.setFromUnitVectors(_up, _normal)
|
||||||
|
_euler.setFromQuaternion(_quat, 'XYZ')
|
||||||
|
|
||||||
|
return [_euler.x, _euler.y, _euler.z]
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { Euler, Matrix3, Quaternion, Vector3 } from 'three'
|
|||||||
import {
|
import {
|
||||||
calculateCursorRotation,
|
calculateCursorRotation,
|
||||||
calculateItemRotation,
|
calculateItemRotation,
|
||||||
|
calculateRoofRotation,
|
||||||
getGridAlignedDimensions,
|
getGridAlignedDimensions,
|
||||||
getSideFromNormal,
|
getSideFromNormal,
|
||||||
isValidWallSideFace,
|
isValidWallSideFace,
|
||||||
@@ -107,7 +108,7 @@ export const floorStrategy = {
|
|||||||
return {
|
return {
|
||||||
gridPosition: [x, 0, z],
|
gridPosition: [x, 0, z],
|
||||||
cursorPosition: [x, event.localPosition[1], z],
|
cursorPosition: [x, event.localPosition[1], z],
|
||||||
cursorRotationY: 0,
|
cursorRotationY: rotY,
|
||||||
nodeUpdate: { position: [x, 0, z] },
|
nodeUpdate: { position: [x, 0, z] },
|
||||||
stopPropagation: false,
|
stopPropagation: false,
|
||||||
dirtyNodeId: null,
|
dirtyNodeId: null,
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export interface PlacementResult {
|
|||||||
gridPosition: [number, number, number]
|
gridPosition: [number, number, number]
|
||||||
cursorPosition: [number, number, number]
|
cursorPosition: [number, number, number]
|
||||||
cursorRotationY: number
|
cursorRotationY: number
|
||||||
|
cursorRotation?: [number, number, number]
|
||||||
nodeUpdate: Partial<ItemNode> | null
|
nodeUpdate: Partial<ItemNode> | null
|
||||||
stopPropagation: boolean
|
stopPropagation: boolean
|
||||||
dirtyNodeId: AnyNode['id'] | null
|
dirtyNodeId: AnyNode['id'] | null
|
||||||
@@ -79,6 +80,7 @@ export interface TransitionResult {
|
|||||||
gridPosition: [number, number, number]
|
gridPosition: [number, number, number]
|
||||||
cursorPosition: [number, number, number]
|
cursorPosition: [number, number, number]
|
||||||
cursorRotationY: number
|
cursorRotationY: number
|
||||||
|
cursorRotation?: [number, number, number]
|
||||||
stopPropagation: boolean
|
stopPropagation: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { Html } from '@react-three/drei'
|
import { Html } from '@react-three/drei'
|
||||||
import { useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
BufferGeometry,
|
BufferGeometry,
|
||||||
Euler,
|
Euler,
|
||||||
@@ -317,7 +317,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
const { asset, draftNode } = config
|
const { asset, draftNode } = config
|
||||||
const unit = useViewer((state) => state.unit)
|
const unit = useViewer((state) => state.unit)
|
||||||
const gridSnapStep = useEditor((s) => s.gridSnapStep)
|
const gridSnapStep = useEditor((s) => s.gridSnapStep)
|
||||||
const updatePreviewGeometry = (bounds: PreviewBounds) => {
|
const updatePreviewGeometry = useCallback((bounds: PreviewBounds) => {
|
||||||
const [width, height, depth] = bounds.dimensions
|
const [width, height, depth] = bounds.dimensions
|
||||||
const [centerX, centerY, centerZ] = bounds.center
|
const [centerX, centerY, centerZ] = bounds.center
|
||||||
const signature = `${width.toFixed(4)}:${height.toFixed(4)}:${depth.toFixed(4)}:${centerX.toFixed(4)}:${centerY.toFixed(4)}:${centerZ.toFixed(4)}`
|
const signature = `${width.toFixed(4)}:${height.toFixed(4)}:${depth.toFixed(4)}:${centerX.toFixed(4)}:${centerY.toFixed(4)}:${centerZ.toFixed(4)}`
|
||||||
@@ -334,9 +334,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
const oldBasePlaneGeometry = basePlaneRef.current.geometry
|
const oldBasePlaneGeometry = basePlaneRef.current.geometry
|
||||||
basePlaneRef.current.geometry = nextBasePlaneGeometry
|
basePlaneRef.current.geometry = nextBasePlaneGeometry
|
||||||
oldBasePlaneGeometry.dispose()
|
oldBasePlaneGeometry.dispose()
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
const updateDimensionGuides = (bounds: PreviewBounds) => {
|
const updateDimensionGuides = useCallback((bounds: PreviewBounds) => {
|
||||||
setDimensionBounds((current) => {
|
setDimensionBounds((current) => {
|
||||||
if (
|
if (
|
||||||
current &&
|
current &&
|
||||||
@@ -438,7 +438,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
applyPoints(measurementWidthRef, widthPoints)
|
applyPoints(measurementWidthRef, widthPoints)
|
||||||
applyPoints(measurementDepthRef, depthPoints)
|
applyPoints(measurementDepthRef, depthPoints)
|
||||||
applyPoints(measurementHeightRef, heightPoints)
|
applyPoints(measurementHeightRef, heightPoints)
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!asset) return
|
if (!asset) return
|
||||||
@@ -502,7 +502,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
|
|
||||||
const c = worldToBuildingLocal(...result.cursorPosition)
|
const c = worldToBuildingLocal(...result.cursorPosition)
|
||||||
cursorGroupRef.current.position.set(c.x, c.y, c.z)
|
cursorGroupRef.current.position.set(c.x, c.y, c.z)
|
||||||
cursorGroupRef.current.rotation.y = result.cursorRotationY
|
if (result.cursorRotation) {
|
||||||
|
cursorGroupRef.current.rotation.set(...result.cursorRotation)
|
||||||
|
} else {
|
||||||
|
cursorGroupRef.current.rotation.set(0, result.cursorRotationY, 0)
|
||||||
|
}
|
||||||
|
|
||||||
const draft = draftNode.current
|
const draft = draftNode.current
|
||||||
if (draft) {
|
if (draft) {
|
||||||
@@ -516,12 +520,18 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
gridPosition.current.set(...result.gridPosition)
|
gridPosition.current.set(...result.gridPosition)
|
||||||
const c = worldToBuildingLocal(...result.cursorPosition)
|
const c = worldToBuildingLocal(...result.cursorPosition)
|
||||||
cursorGroupRef.current.position.set(c.x, c.y, c.z)
|
cursorGroupRef.current.position.set(c.x, c.y, c.z)
|
||||||
cursorGroupRef.current.rotation.y = result.cursorRotationY
|
if (result.cursorRotation) {
|
||||||
|
cursorGroupRef.current.rotation.set(...result.cursorRotation)
|
||||||
|
} else {
|
||||||
|
cursorGroupRef.current.rotation.set(0, result.cursorRotationY, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const initRotation: [number, number, number] = result.cursorRotation ?? [0, result.cursorRotationY, 0]
|
||||||
|
|
||||||
draftNode.create(
|
draftNode.create(
|
||||||
gridPosition.current,
|
gridPosition.current,
|
||||||
asset,
|
asset,
|
||||||
[0, result.cursorRotationY, 0],
|
initRotation,
|
||||||
configRef.current.defaultScale,
|
configRef.current.defaultScale,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -532,6 +542,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
useScene.getState().updateNode(draft.id, result.nodeUpdate)
|
useScene.getState().updateNode(draft.id, result.nodeUpdate)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const previewBounds = expandBoundsToGrid(
|
||||||
|
getFallbackPreviewBounds(draftNode.current, asset, asset.attachTo),
|
||||||
|
asset.attachTo,
|
||||||
|
gridSnapStep,
|
||||||
|
)
|
||||||
|
updatePreviewGeometry(previewBounds)
|
||||||
|
updateDimensionGuides(previewBounds)
|
||||||
|
|
||||||
if (!revalidate()) {
|
if (!revalidate()) {
|
||||||
draftNode.destroy()
|
draftNode.destroy()
|
||||||
}
|
}
|
||||||
@@ -548,10 +566,18 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
mesh.getWorldPosition(worldPos)
|
mesh.getWorldPosition(worldPos)
|
||||||
const localPos = worldToBuildingLocal(worldPos.x, worldPos.y, worldPos.z)
|
const localPos = worldToBuildingLocal(worldPos.x, worldPos.y, worldPos.z)
|
||||||
cursorGroupRef.current.position.copy(localPos)
|
cursorGroupRef.current.position.copy(localPos)
|
||||||
// Extract world Y rotation (handles wall-parented items correctly)
|
if (draftNode.current.asset.attachTo) {
|
||||||
const q = new Quaternion()
|
// Wall/ceiling items: extract world Y rotation (handles wall-parented items correctly)
|
||||||
mesh.getWorldQuaternion(q)
|
const q = new Quaternion()
|
||||||
cursorGroupRef.current.rotation.y = new Euler().setFromQuaternion(q, 'YXZ').y
|
mesh.getWorldQuaternion(q)
|
||||||
|
cursorGroupRef.current.rotation.y = new Euler().setFromQuaternion(q, 'YXZ').y
|
||||||
|
} else {
|
||||||
|
// Floor items: the cursor group lives in building-local space, so use the
|
||||||
|
// node's local Y rotation — the same value onGridMove applies. The world
|
||||||
|
// quaternion would double-count any building rotation, leaving the initial
|
||||||
|
// box mis-rotated until the first cursor move.
|
||||||
|
cursorGroupRef.current.rotation.y = draftNode.current.rotation[1] ?? 0
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
cursorGroupRef.current.position.copy(gridPosition.current)
|
cursorGroupRef.current.position.copy(gridPosition.current)
|
||||||
cursorGroupRef.current.rotation.y = draftNode.current.rotation[1] ?? 0
|
cursorGroupRef.current.rotation.y = draftNode.current.rotation[1] ?? 0
|
||||||
@@ -591,6 +617,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
result.cursorPosition[1],
|
result.cursorPosition[1],
|
||||||
result.cursorPosition[2],
|
result.cursorPosition[2],
|
||||||
)
|
)
|
||||||
|
// Floor items only rotate on Y; keep the preview box (and the live
|
||||||
|
// transform the 2D floorplan mirrors) aligned with the draft's
|
||||||
|
// rotation. Without this the box stays at its seed rotation until a
|
||||||
|
// manual R/T, so a moved already-rotated item shows an axis-aligned box.
|
||||||
|
cursorGroupRef.current.rotation.y = result.cursorRotationY
|
||||||
|
|
||||||
const draft = draftNode.current
|
const draft = draftNode.current
|
||||||
if (draft) draft.position = result.gridPosition
|
if (draft) draft.position = result.gridPosition
|
||||||
@@ -621,6 +652,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
draftNode.commit(result.nodeUpdate)
|
draftNode.commit(result.nodeUpdate)
|
||||||
if (configRef.current.onCommitted()) {
|
if (configRef.current.onCommitted()) {
|
||||||
draftNode.create(gridPosition.current, asset, currentRotation)
|
draftNode.create(gridPosition.current, asset, currentRotation)
|
||||||
|
const previewBounds = expandBoundsToGrid(
|
||||||
|
getFallbackPreviewBounds(draftNode.current, asset, asset.attachTo),
|
||||||
|
asset.attachTo,
|
||||||
|
gridSnapStep,
|
||||||
|
)
|
||||||
|
updatePreviewGeometry(previewBounds)
|
||||||
|
updateDimensionGuides(previewBounds)
|
||||||
revalidate()
|
revalidate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import useEditor from '../../../store/use-editor'
|
|||||||
import { CursorSphere } from '../shared/cursor-sphere'
|
import { CursorSphere } from '../shared/cursor-sphere'
|
||||||
|
|
||||||
const DEFAULT_WALL_HEIGHT = 0.5
|
const DEFAULT_WALL_HEIGHT = 0.5
|
||||||
const DEFAULT_ROOF_HEIGHT = 2.5
|
const DEFAULT_PITCH_DEG = 40
|
||||||
const GRID_OFFSET = 0.02
|
const GRID_OFFSET = 0.02
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -77,7 +77,7 @@ const commitRoofPlacement = (
|
|||||||
width,
|
width,
|
||||||
depth,
|
depth,
|
||||||
wallHeight: DEFAULT_WALL_HEIGHT,
|
wallHeight: DEFAULT_WALL_HEIGHT,
|
||||||
roofHeight: DEFAULT_ROOF_HEIGHT,
|
pitch: DEFAULT_PITCH_DEG,
|
||||||
roofType: 'gable',
|
roofType: 'gable',
|
||||||
position: [localX, 0, localZ],
|
position: [localX, 0, localZ],
|
||||||
})
|
})
|
||||||
@@ -96,7 +96,7 @@ const commitRoofPlacement = (
|
|||||||
width,
|
width,
|
||||||
depth,
|
depth,
|
||||||
wallHeight: DEFAULT_WALL_HEIGHT,
|
wallHeight: DEFAULT_WALL_HEIGHT,
|
||||||
roofHeight: DEFAULT_ROOF_HEIGHT,
|
pitch: DEFAULT_PITCH_DEG,
|
||||||
roofType: 'gable',
|
roofType: 'gable',
|
||||||
position: [0, 0, 0],
|
position: [0, 0, 0],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ export const tools: ToolConfig[] = [
|
|||||||
{ id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' },
|
{ id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' },
|
||||||
{ id: 'spawn', iconSrc: '/icons/site.png', label: 'Spawn Point' },
|
{ id: 'spawn', iconSrc: '/icons/site.png', label: 'Spawn Point' },
|
||||||
{ id: 'shelf', iconSrc: '/icons/shelf.png', label: 'Shelf' },
|
{ id: 'shelf', iconSrc: '/icons/shelf.png', label: 'Shelf' },
|
||||||
|
// Roof-mounted accessories (box-vent / ridge-vent / chimney /
|
||||||
|
// solar-panel / skylight / dormer) are intentionally NOT in the top
|
||||||
|
// palette — they only make sense in the context of a selected roof.
|
||||||
|
// The roof inspector's "Add element" section is the entry point
|
||||||
|
// (`packages/nodes/src/roof/panel.tsx`), which activates the same
|
||||||
|
// registry-driven placement tools via `setTool(kind)`.
|
||||||
]
|
]
|
||||||
|
|
||||||
export function StructureTools() {
|
export function StructureTools() {
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import {
|
|||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
type BuildingNode,
|
type BuildingNode,
|
||||||
type CeilingNode,
|
type CeilingNode,
|
||||||
|
type ChimneyNode,
|
||||||
type ColumnNode,
|
type ColumnNode,
|
||||||
type DoorNode,
|
type DoorNode,
|
||||||
|
type DormerNode,
|
||||||
type ElevatorNode,
|
type ElevatorNode,
|
||||||
type FenceNode,
|
type FenceNode,
|
||||||
type ItemNode,
|
type ItemNode,
|
||||||
@@ -37,7 +39,9 @@ type MovableNode =
|
|||||||
| DoorNode
|
| DoorNode
|
||||||
| ElevatorNode
|
| ElevatorNode
|
||||||
| CeilingNode
|
| CeilingNode
|
||||||
|
| ChimneyNode
|
||||||
| ColumnNode
|
| ColumnNode
|
||||||
|
| DormerNode
|
||||||
| SlabNode
|
| SlabNode
|
||||||
| WallNode
|
| WallNode
|
||||||
| FenceNode
|
| FenceNode
|
||||||
@@ -53,7 +57,9 @@ const MOVABLE_TYPES = new Set<string>([
|
|||||||
'door',
|
'door',
|
||||||
'elevator',
|
'elevator',
|
||||||
'ceiling',
|
'ceiling',
|
||||||
|
'chimney',
|
||||||
'column',
|
'column',
|
||||||
|
'dormer',
|
||||||
'slab',
|
'slab',
|
||||||
'wall',
|
'wall',
|
||||||
'fence',
|
'fence',
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { type AnyNodeId, type BoxVentNode, useScene } from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import { memo, useCallback, useState } from 'react'
|
||||||
|
import useEditor from './../../../../../store/use-editor'
|
||||||
|
import { InlineRenameInput } from './inline-rename-input'
|
||||||
|
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
||||||
|
import { TreeNodeActions } from './tree-node-actions'
|
||||||
|
|
||||||
|
interface BoxVentTreeNodeProps {
|
||||||
|
nodeId: AnyNodeId
|
||||||
|
depth: number
|
||||||
|
isLast?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BoxVentTreeNode = memo(function BoxVentTreeNode({
|
||||||
|
nodeId,
|
||||||
|
depth,
|
||||||
|
isLast,
|
||||||
|
}: BoxVentTreeNodeProps) {
|
||||||
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
|
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
|
||||||
|
const node = useScene((s) => s.nodes[nodeId] as BoxVentNode | undefined)
|
||||||
|
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
|
||||||
|
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
||||||
|
const setSelection = useViewer((state) => state.setSelection)
|
||||||
|
const setHoveredId = useViewer((state) => state.setHoveredId)
|
||||||
|
|
||||||
|
const handleClick = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
const handled = handleTreeSelection(
|
||||||
|
e,
|
||||||
|
nodeId,
|
||||||
|
useViewer.getState().selection.selectedIds,
|
||||||
|
setSelection,
|
||||||
|
)
|
||||||
|
if (!handled && useEditor.getState().phase === 'furnish') {
|
||||||
|
useEditor.getState().setPhase('structure')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[nodeId, setSelection],
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultName = node?.name || 'Box Vent'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TreeNodeWrapper
|
||||||
|
actions={<TreeNodeActions nodeId={nodeId} />}
|
||||||
|
depth={depth}
|
||||||
|
expanded={false}
|
||||||
|
hasChildren={false}
|
||||||
|
icon={
|
||||||
|
<Image
|
||||||
|
alt=""
|
||||||
|
className="object-contain opacity-60"
|
||||||
|
height={14}
|
||||||
|
src="/icons/roof.png"
|
||||||
|
width={14}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
isHovered={isHovered}
|
||||||
|
isLast={isLast}
|
||||||
|
isSelected={isSelected}
|
||||||
|
isVisible={isVisible}
|
||||||
|
label={
|
||||||
|
<InlineRenameInput
|
||||||
|
defaultName={defaultName}
|
||||||
|
isEditing={isEditing}
|
||||||
|
nodeId={nodeId}
|
||||||
|
onStartEditing={() => setIsEditing(true)}
|
||||||
|
onStopEditing={() => setIsEditing(false)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
nodeId={nodeId}
|
||||||
|
onClick={handleClick}
|
||||||
|
onDoubleClick={() => focusTreeNode(nodeId)}
|
||||||
|
onMouseEnter={() => setHoveredId(nodeId)}
|
||||||
|
onMouseLeave={() => setHoveredId(null)}
|
||||||
|
onToggle={() => {}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { type AnyNodeId, type ChimneyNode, useScene } from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import { memo, useCallback, useState } from 'react'
|
||||||
|
import useEditor from './../../../../../store/use-editor'
|
||||||
|
import { InlineRenameInput } from './inline-rename-input'
|
||||||
|
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
||||||
|
import { TreeNodeActions } from './tree-node-actions'
|
||||||
|
|
||||||
|
interface ChimneyTreeNodeProps {
|
||||||
|
nodeId: AnyNodeId
|
||||||
|
depth: number
|
||||||
|
isLast?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChimneyTreeNode = memo(function ChimneyTreeNode({
|
||||||
|
nodeId,
|
||||||
|
depth,
|
||||||
|
isLast,
|
||||||
|
}: ChimneyTreeNodeProps) {
|
||||||
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
|
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
|
||||||
|
const node = useScene((s) => s.nodes[nodeId] as ChimneyNode | undefined)
|
||||||
|
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
|
||||||
|
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
||||||
|
const setSelection = useViewer((state) => state.setSelection)
|
||||||
|
const setHoveredId = useViewer((state) => state.setHoveredId)
|
||||||
|
|
||||||
|
const handleClick = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
const handled = handleTreeSelection(
|
||||||
|
e,
|
||||||
|
nodeId,
|
||||||
|
useViewer.getState().selection.selectedIds,
|
||||||
|
setSelection,
|
||||||
|
)
|
||||||
|
if (!handled && useEditor.getState().phase === 'furnish') {
|
||||||
|
useEditor.getState().setPhase('structure')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[nodeId, setSelection],
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultName = node?.name || 'Chimney'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TreeNodeWrapper
|
||||||
|
actions={<TreeNodeActions nodeId={nodeId} />}
|
||||||
|
depth={depth}
|
||||||
|
expanded={false}
|
||||||
|
hasChildren={false}
|
||||||
|
icon={
|
||||||
|
<Image
|
||||||
|
alt=""
|
||||||
|
className="object-contain opacity-60"
|
||||||
|
height={14}
|
||||||
|
src="/icons/roof.png"
|
||||||
|
width={14}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
isHovered={isHovered}
|
||||||
|
isLast={isLast}
|
||||||
|
isSelected={isSelected}
|
||||||
|
isVisible={isVisible}
|
||||||
|
label={
|
||||||
|
<InlineRenameInput
|
||||||
|
defaultName={defaultName}
|
||||||
|
isEditing={isEditing}
|
||||||
|
nodeId={nodeId}
|
||||||
|
onStartEditing={() => setIsEditing(true)}
|
||||||
|
onStopEditing={() => setIsEditing(false)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
nodeId={nodeId}
|
||||||
|
onClick={handleClick}
|
||||||
|
onDoubleClick={() => focusTreeNode(nodeId)}
|
||||||
|
onMouseEnter={() => setHoveredId(nodeId)}
|
||||||
|
onMouseLeave={() => setHoveredId(null)}
|
||||||
|
onToggle={() => {}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { type AnyNodeId, type DormerNode, useScene } from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import { memo, useCallback, useState } from 'react'
|
||||||
|
import useEditor from './../../../../../store/use-editor'
|
||||||
|
import { InlineRenameInput } from './inline-rename-input'
|
||||||
|
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
||||||
|
import { TreeNodeActions } from './tree-node-actions'
|
||||||
|
|
||||||
|
interface DormerTreeNodeProps {
|
||||||
|
nodeId: AnyNodeId
|
||||||
|
depth: number
|
||||||
|
isLast?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sidebar tree-node entry for a dormer. Mirrors `ChimneyTreeNode`
|
||||||
|
* exactly — dormers are leaf entries (no children) parented under
|
||||||
|
* their host roof segment. The `roof.png` icon matches the rest of
|
||||||
|
* the roof-accessory kinds.
|
||||||
|
*/
|
||||||
|
export const DormerTreeNode = memo(function DormerTreeNode({
|
||||||
|
nodeId,
|
||||||
|
depth,
|
||||||
|
isLast,
|
||||||
|
}: DormerTreeNodeProps) {
|
||||||
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
|
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
|
||||||
|
const node = useScene((s) => s.nodes[nodeId] as DormerNode | undefined)
|
||||||
|
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
|
||||||
|
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
||||||
|
const setSelection = useViewer((state) => state.setSelection)
|
||||||
|
const setHoveredId = useViewer((state) => state.setHoveredId)
|
||||||
|
|
||||||
|
const handleClick = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
const handled = handleTreeSelection(
|
||||||
|
e,
|
||||||
|
nodeId,
|
||||||
|
useViewer.getState().selection.selectedIds,
|
||||||
|
setSelection,
|
||||||
|
)
|
||||||
|
if (!handled && useEditor.getState().phase === 'furnish') {
|
||||||
|
useEditor.getState().setPhase('structure')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[nodeId, setSelection],
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultName = node?.name || 'Dormer'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TreeNodeWrapper
|
||||||
|
actions={<TreeNodeActions nodeId={nodeId} />}
|
||||||
|
depth={depth}
|
||||||
|
expanded={false}
|
||||||
|
hasChildren={false}
|
||||||
|
icon={
|
||||||
|
<Image
|
||||||
|
alt=""
|
||||||
|
className="object-contain opacity-60"
|
||||||
|
height={14}
|
||||||
|
src="/icons/roof.png"
|
||||||
|
width={14}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
isHovered={isHovered}
|
||||||
|
isLast={isLast}
|
||||||
|
isSelected={isSelected}
|
||||||
|
isVisible={isVisible}
|
||||||
|
label={
|
||||||
|
<InlineRenameInput
|
||||||
|
defaultName={defaultName}
|
||||||
|
isEditing={isEditing}
|
||||||
|
nodeId={nodeId}
|
||||||
|
onStartEditing={() => setIsEditing(true)}
|
||||||
|
onStopEditing={() => setIsEditing(false)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
nodeId={nodeId}
|
||||||
|
onClick={handleClick}
|
||||||
|
onDoubleClick={() => focusTreeNode(nodeId)}
|
||||||
|
onMouseEnter={() => setHoveredId(nodeId)}
|
||||||
|
onMouseLeave={() => setHoveredId(null)}
|
||||||
|
onToggle={() => {}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { type AnyNodeId, type RidgeVentNode, useScene } from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import { memo, useCallback, useState } from 'react'
|
||||||
|
import useEditor from './../../../../../store/use-editor'
|
||||||
|
import { InlineRenameInput } from './inline-rename-input'
|
||||||
|
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
||||||
|
import { TreeNodeActions } from './tree-node-actions'
|
||||||
|
|
||||||
|
interface RidgeVentTreeNodeProps {
|
||||||
|
nodeId: AnyNodeId
|
||||||
|
depth: number
|
||||||
|
isLast?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RidgeVentTreeNode = memo(function RidgeVentTreeNode({
|
||||||
|
nodeId,
|
||||||
|
depth,
|
||||||
|
isLast,
|
||||||
|
}: RidgeVentTreeNodeProps) {
|
||||||
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
|
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
|
||||||
|
const node = useScene((s) => s.nodes[nodeId] as RidgeVentNode | undefined)
|
||||||
|
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
|
||||||
|
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
||||||
|
const setSelection = useViewer((state) => state.setSelection)
|
||||||
|
const setHoveredId = useViewer((state) => state.setHoveredId)
|
||||||
|
|
||||||
|
const handleClick = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
const handled = handleTreeSelection(
|
||||||
|
e,
|
||||||
|
nodeId,
|
||||||
|
useViewer.getState().selection.selectedIds,
|
||||||
|
setSelection,
|
||||||
|
)
|
||||||
|
if (!handled && useEditor.getState().phase === 'furnish') {
|
||||||
|
useEditor.getState().setPhase('structure')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[nodeId, setSelection],
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultName = node?.name || 'Ridge Vent'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TreeNodeWrapper
|
||||||
|
actions={<TreeNodeActions nodeId={nodeId} />}
|
||||||
|
depth={depth}
|
||||||
|
expanded={false}
|
||||||
|
hasChildren={false}
|
||||||
|
icon={
|
||||||
|
<Image
|
||||||
|
alt=""
|
||||||
|
className="object-contain opacity-60"
|
||||||
|
height={14}
|
||||||
|
src="/icons/roof.png"
|
||||||
|
width={14}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
isHovered={isHovered}
|
||||||
|
isLast={isLast}
|
||||||
|
isSelected={isSelected}
|
||||||
|
isVisible={isVisible}
|
||||||
|
label={
|
||||||
|
<InlineRenameInput
|
||||||
|
defaultName={defaultName}
|
||||||
|
isEditing={isEditing}
|
||||||
|
nodeId={nodeId}
|
||||||
|
onStartEditing={() => setIsEditing(true)}
|
||||||
|
onStopEditing={() => setIsEditing(false)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
nodeId={nodeId}
|
||||||
|
onClick={handleClick}
|
||||||
|
onDoubleClick={() => focusTreeNode(nodeId)}
|
||||||
|
onMouseEnter={() => setHoveredId(nodeId)}
|
||||||
|
onMouseLeave={() => setHoveredId(null)}
|
||||||
|
onToggle={() => {}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -6,7 +6,7 @@ import { memo, useCallback, useEffect, useState } from 'react'
|
|||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import useEditor from '../../../../../store/use-editor'
|
import useEditor from '../../../../../store/use-editor'
|
||||||
import { InlineRenameInput } from './inline-rename-input'
|
import { InlineRenameInput } from './inline-rename-input'
|
||||||
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
import { focusTreeNode, handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node'
|
||||||
import { TreeNodeActions } from './tree-node-actions'
|
import { TreeNodeActions } from './tree-node-actions'
|
||||||
import { DropIndicatorLine, useTreeNodeDrag } from './tree-node-drag'
|
import { DropIndicatorLine, useTreeNodeDrag } from './tree-node-drag'
|
||||||
|
|
||||||
@@ -163,12 +163,36 @@ function RoofSegmentTreeNode({
|
|||||||
isLast?: boolean
|
isLast?: boolean
|
||||||
}) {
|
}) {
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
|
const [expanded, setExpanded] = useState(true)
|
||||||
|
const accessoryIds = useScene(
|
||||||
|
useShallow(
|
||||||
|
(s) => (s.nodes[node.id] as RoofSegmentNode | undefined)?.children ?? ([] as string[]),
|
||||||
|
),
|
||||||
|
)
|
||||||
const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id))
|
const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id))
|
||||||
const isHovered = useViewer((state) => state.hoveredId === node.id)
|
const isHovered = useViewer((state) => state.hoveredId === node.id)
|
||||||
const setSelection = useViewer((state) => state.setSelection)
|
const setSelection = useViewer((state) => state.setSelection)
|
||||||
const setHoveredId = useViewer((state) => state.setHoveredId)
|
const setHoveredId = useViewer((state) => state.setHoveredId)
|
||||||
const { startDrag, isDragging } = useTreeNodeDrag()
|
const { startDrag, isDragging } = useTreeNodeDrag()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return useViewer.subscribe((state) => {
|
||||||
|
const { selectedIds } = state.selection
|
||||||
|
if (selectedIds.length === 0) return
|
||||||
|
const nodes = useScene.getState().nodes
|
||||||
|
for (const id of selectedIds) {
|
||||||
|
let current = nodes[id as AnyNodeId]
|
||||||
|
while (current?.parentId) {
|
||||||
|
if (current.parentId === node.id) {
|
||||||
|
setExpanded(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
current = nodes[current.parentId as AnyNodeId]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [node.id])
|
||||||
|
|
||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
if (isDragging) return
|
if (isDragging) return
|
||||||
@@ -192,13 +216,15 @@ function RoofSegmentTreeNode({
|
|||||||
|
|
||||||
const defaultName = `${node.roofType.charAt(0).toUpperCase() + node.roofType.slice(1)} (${node.width.toFixed(1)}x${node.depth.toFixed(1)}m)`
|
const defaultName = `${node.roofType.charAt(0).toUpperCase() + node.roofType.slice(1)} (${node.width.toFixed(1)}x${node.depth.toFixed(1)}m)`
|
||||||
|
|
||||||
|
const hasAccessories = accessoryIds.length > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-drop-child={node.id}>
|
<div data-drop-child={node.id}>
|
||||||
<TreeNodeWrapper
|
<TreeNodeWrapper
|
||||||
actions={<TreeNodeActions nodeId={node.id} />}
|
actions={<TreeNodeActions nodeId={node.id} />}
|
||||||
depth={depth}
|
depth={depth}
|
||||||
expanded={false}
|
expanded={expanded}
|
||||||
hasChildren={false}
|
hasChildren={hasAccessories}
|
||||||
icon={
|
icon={
|
||||||
<Image
|
<Image
|
||||||
alt=""
|
alt=""
|
||||||
@@ -228,8 +254,18 @@ function RoofSegmentTreeNode({
|
|||||||
onMouseEnter={() => setHoveredId(node.id)}
|
onMouseEnter={() => setHoveredId(node.id)}
|
||||||
onMouseLeave={() => setHoveredId(null)}
|
onMouseLeave={() => setHoveredId(null)}
|
||||||
onPointerDown={handlePointerDown}
|
onPointerDown={handlePointerDown}
|
||||||
onToggle={() => {}}
|
onToggle={() => setExpanded((prev) => !prev)}
|
||||||
/>
|
>
|
||||||
|
{hasAccessories &&
|
||||||
|
accessoryIds.map((childId, index) => (
|
||||||
|
<TreeNode
|
||||||
|
depth={depth + 1}
|
||||||
|
isLast={index === accessoryIds.length - 1}
|
||||||
|
key={childId}
|
||||||
|
nodeId={childId as AnyNodeId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</TreeNodeWrapper>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { type AnyNodeId, type SolarPanelNode, useScene } from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import { memo, useCallback, useState } from 'react'
|
||||||
|
import useEditor from './../../../../../store/use-editor'
|
||||||
|
import { InlineRenameInput } from './inline-rename-input'
|
||||||
|
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
||||||
|
import { TreeNodeActions } from './tree-node-actions'
|
||||||
|
|
||||||
|
interface SolarPanelTreeNodeProps {
|
||||||
|
nodeId: AnyNodeId
|
||||||
|
depth: number
|
||||||
|
isLast?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SolarPanelTreeNode = memo(function SolarPanelTreeNode({
|
||||||
|
nodeId,
|
||||||
|
depth,
|
||||||
|
isLast,
|
||||||
|
}: SolarPanelTreeNodeProps) {
|
||||||
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
|
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
|
||||||
|
const node = useScene((s) => s.nodes[nodeId] as SolarPanelNode | undefined)
|
||||||
|
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
|
||||||
|
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
||||||
|
const setSelection = useViewer((state) => state.setSelection)
|
||||||
|
const setHoveredId = useViewer((state) => state.setHoveredId)
|
||||||
|
|
||||||
|
const handleClick = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
const handled = handleTreeSelection(
|
||||||
|
e,
|
||||||
|
nodeId,
|
||||||
|
useViewer.getState().selection.selectedIds,
|
||||||
|
setSelection,
|
||||||
|
)
|
||||||
|
if (!handled && useEditor.getState().phase === 'furnish') {
|
||||||
|
useEditor.getState().setPhase('structure')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[nodeId, setSelection],
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultName = node?.name || 'Solar Panel'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TreeNodeWrapper
|
||||||
|
actions={<TreeNodeActions nodeId={nodeId} />}
|
||||||
|
depth={depth}
|
||||||
|
expanded={false}
|
||||||
|
hasChildren={false}
|
||||||
|
icon={
|
||||||
|
<Image
|
||||||
|
alt=""
|
||||||
|
className="object-contain opacity-60"
|
||||||
|
height={14}
|
||||||
|
src="/icons/roof.png"
|
||||||
|
width={14}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
isHovered={isHovered}
|
||||||
|
isLast={isLast}
|
||||||
|
isSelected={isSelected}
|
||||||
|
isVisible={isVisible}
|
||||||
|
label={
|
||||||
|
<InlineRenameInput
|
||||||
|
defaultName={defaultName}
|
||||||
|
isEditing={isEditing}
|
||||||
|
nodeId={nodeId}
|
||||||
|
onStartEditing={() => setIsEditing(true)}
|
||||||
|
onStopEditing={() => setIsEditing(false)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
nodeId={nodeId}
|
||||||
|
onClick={handleClick}
|
||||||
|
onDoubleClick={() => focusTreeNode(nodeId)}
|
||||||
|
onMouseEnter={() => setHoveredId(nodeId)}
|
||||||
|
onMouseLeave={() => setHoveredId(null)}
|
||||||
|
onToggle={() => {}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -54,17 +54,22 @@ export function focusTreeNode(nodeId: AnyNodeId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
import { cn } from '../../../../../lib/utils'
|
import { cn } from '../../../../../lib/utils'
|
||||||
|
import { BoxVentTreeNode } from './box-vent-tree-node'
|
||||||
import { BuildingTreeNode } from './building-tree-node'
|
import { BuildingTreeNode } from './building-tree-node'
|
||||||
import { CeilingTreeNode } from './ceiling-tree-node'
|
import { CeilingTreeNode } from './ceiling-tree-node'
|
||||||
|
import { ChimneyTreeNode } from './chimney-tree-node'
|
||||||
import { ColumnTreeNode } from './column-tree-node'
|
import { ColumnTreeNode } from './column-tree-node'
|
||||||
import { DoorTreeNode } from './door-tree-node'
|
import { DoorTreeNode } from './door-tree-node'
|
||||||
|
import { DormerTreeNode } from './dormer-tree-node'
|
||||||
import { ElevatorTreeNode } from './elevator-tree-node'
|
import { ElevatorTreeNode } from './elevator-tree-node'
|
||||||
import { FenceTreeNode } from './fence-tree-node'
|
import { FenceTreeNode } from './fence-tree-node'
|
||||||
import { ItemTreeNode } from './item-tree-node'
|
import { ItemTreeNode } from './item-tree-node'
|
||||||
import { LevelTreeNode } from './level-tree-node'
|
import { LevelTreeNode } from './level-tree-node'
|
||||||
|
import { RidgeVentTreeNode } from './ridge-vent-tree-node'
|
||||||
import { RoofTreeNode } from './roof-tree-node'
|
import { RoofTreeNode } from './roof-tree-node'
|
||||||
import { ShelfTreeNode } from './shelf-tree-node'
|
import { ShelfTreeNode } from './shelf-tree-node'
|
||||||
import { SlabTreeNode } from './slab-tree-node'
|
import { SlabTreeNode } from './slab-tree-node'
|
||||||
|
import { SolarPanelTreeNode } from './solar-panel-tree-node'
|
||||||
import { SpawnTreeNode } from './spawn-tree-node'
|
import { SpawnTreeNode } from './spawn-tree-node'
|
||||||
import { StairTreeNode } from './stair-tree-node'
|
import { StairTreeNode } from './stair-tree-node'
|
||||||
import { WallTreeNode } from './wall-tree-node'
|
import { WallTreeNode } from './wall-tree-node'
|
||||||
@@ -93,7 +98,11 @@ const treeNodeByType: Record<
|
|||||||
isLast?: boolean
|
isLast?: boolean
|
||||||
nodeId: AnyNodeId
|
nodeId: AnyNodeId
|
||||||
}>,
|
}>,
|
||||||
|
'box-vent': BoxVentTreeNode,
|
||||||
ceiling: CeilingTreeNode,
|
ceiling: CeilingTreeNode,
|
||||||
|
chimney: ChimneyTreeNode,
|
||||||
|
dormer: DormerTreeNode,
|
||||||
|
'solar-panel': SolarPanelTreeNode,
|
||||||
column: ColumnTreeNode,
|
column: ColumnTreeNode,
|
||||||
elevator: ElevatorTreeNode,
|
elevator: ElevatorTreeNode,
|
||||||
level: LevelTreeNode as React.ComponentType<{
|
level: LevelTreeNode as React.ComponentType<{
|
||||||
@@ -114,6 +123,7 @@ const treeNodeByType: Record<
|
|||||||
}>,
|
}>,
|
||||||
wall: WallTreeNode,
|
wall: WallTreeNode,
|
||||||
fence: FenceTreeNode,
|
fence: FenceTreeNode,
|
||||||
|
'ridge-vent': RidgeVentTreeNode,
|
||||||
roof: RoofTreeNode,
|
roof: RoofTreeNode,
|
||||||
stair: StairTreeNode,
|
stair: StairTreeNode,
|
||||||
door: DoorTreeNode,
|
door: DoorTreeNode,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type AnyNodeId, emitter, useScene } from '@pascal-app/core'
|
import { type AnyNodeId, emitter, nodeRegistry, useScene } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { closeDoorOpenState, toggleDoorOpenState } from '../lib/door-interaction'
|
import { closeDoorOpenState, toggleDoorOpenState } from '../lib/door-interaction'
|
||||||
@@ -197,6 +197,15 @@ export const useKeyboard = ({
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
toggleWindowOpenState(node.id)
|
toggleWindowOpenState(node.id)
|
||||||
sfxEmitter.emit('sfx:item-rotate')
|
sfxEmitter.emit('sfx:item-rotate')
|
||||||
|
} else if (node && nodeRegistry.get(node.type)?.keyboardActions?.r?.appliesTo(node)) {
|
||||||
|
// Registry-driven R action. Skylight uses this for open/
|
||||||
|
// close toggling; future kinds with custom R behaviour
|
||||||
|
// declare it on their `def.keyboardActions` without
|
||||||
|
// touching this hook. Door / window still use the legacy
|
||||||
|
// direct calls above (follow-up to migrate).
|
||||||
|
e.preventDefault()
|
||||||
|
nodeRegistry.get(node.type)?.keyboardActions?.r?.run(node)
|
||||||
|
sfxEmitter.emit('sfx:item-rotate')
|
||||||
} else if (node && 'rotation' in node) {
|
} else if (node && 'rotation' in node) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const ROTATION_STEP = Math.PI / 4
|
const ROTATION_STEP = Math.PI / 4
|
||||||
@@ -237,6 +246,11 @@ export const useKeyboard = ({
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
closeWindowOpenState(node.id)
|
closeWindowOpenState(node.id)
|
||||||
sfxEmitter.emit('sfx:item-rotate')
|
sfxEmitter.emit('sfx:item-rotate')
|
||||||
|
} else if (node && nodeRegistry.get(node.type)?.keyboardActions?.t?.appliesTo(node)) {
|
||||||
|
// Registry-driven T action. Same shape as the R arm above.
|
||||||
|
e.preventDefault()
|
||||||
|
nodeRegistry.get(node.type)?.keyboardActions?.t?.run(node)
|
||||||
|
sfxEmitter.emit('sfx:item-rotate')
|
||||||
} else if (node && 'rotation' in node) {
|
} else if (node && 'rotation' in node) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const ROTATION_STEP = Math.PI / 4
|
const ROTATION_STEP = Math.PI / 4
|
||||||
|
|||||||
@@ -1,17 +1,30 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
type AnyNode,
|
||||||
|
type AnyNodeId,
|
||||||
|
type BoxVentNode,
|
||||||
type CeilingNode,
|
type CeilingNode,
|
||||||
|
type ChimneyMaterialRole,
|
||||||
|
type ChimneyNode,
|
||||||
type ColumnNode,
|
type ColumnNode,
|
||||||
|
type DormerNode,
|
||||||
|
type DormerSurfaceMaterialRole,
|
||||||
type FenceNode,
|
type FenceNode,
|
||||||
getCatalogMaterialById,
|
getCatalogMaterialById,
|
||||||
|
getEffectiveDormerSurfaceMaterial,
|
||||||
getEffectiveRoofSurfaceMaterial,
|
getEffectiveRoofSurfaceMaterial,
|
||||||
getEffectiveStairSurfaceMaterial,
|
getEffectiveStairSurfaceMaterial,
|
||||||
getEffectiveWallSurfaceMaterial,
|
getEffectiveWallSurfaceMaterial,
|
||||||
getLibraryMaterialIdFromRef,
|
getLibraryMaterialIdFromRef,
|
||||||
type MaterialSchema,
|
type MaterialSchema,
|
||||||
type MaterialTarget,
|
type MaterialTarget,
|
||||||
|
nodeRegistry,
|
||||||
|
type RidgeVentNode,
|
||||||
|
getEffectiveSegmentSurfaceMaterial,
|
||||||
type RoofNode,
|
type RoofNode,
|
||||||
|
type RoofSegmentNode,
|
||||||
|
type RoofSegmentSurfaceMaterialRole,
|
||||||
type RoofSurfaceMaterialRole,
|
type RoofSurfaceMaterialRole,
|
||||||
type ShelfNode,
|
type ShelfNode,
|
||||||
type SlabNode,
|
type SlabNode,
|
||||||
@@ -23,7 +36,18 @@ import {
|
|||||||
|
|
||||||
export type PaintableMaterialTarget = Extract<
|
export type PaintableMaterialTarget = Extract<
|
||||||
MaterialTarget,
|
MaterialTarget,
|
||||||
'wall' | 'roof' | 'stair' | 'fence' | 'column' | 'slab' | 'ceiling' | 'shelf'
|
| 'wall'
|
||||||
|
| 'roof'
|
||||||
|
| 'stair'
|
||||||
|
| 'fence'
|
||||||
|
| 'column'
|
||||||
|
| 'slab'
|
||||||
|
| 'ceiling'
|
||||||
|
| 'shelf'
|
||||||
|
| 'chimney'
|
||||||
|
| 'dormer'
|
||||||
|
| 'box-vent'
|
||||||
|
| 'ridge-vent'
|
||||||
>
|
>
|
||||||
|
|
||||||
export type SingleSurfaceMaterialRole = 'surface'
|
export type SingleSurfaceMaterialRole = 'surface'
|
||||||
@@ -105,6 +129,74 @@ export function buildRoofSurfaceMaterialPatch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a per-segment paint patch for one of the three surface roles. The
|
||||||
|
* segment ends up with role-specific fields set (and the legacy catch-all
|
||||||
|
* `material` cleared) so subsequent reads pick the role override over any
|
||||||
|
* parent-roof fallback.
|
||||||
|
*/
|
||||||
|
export function buildRoofSegmentSurfaceMaterialPatch(
|
||||||
|
node: RoofSegmentNode,
|
||||||
|
targetRole: RoofSegmentSurfaceMaterialRole,
|
||||||
|
material: MaterialSchema | undefined,
|
||||||
|
materialPreset: string | undefined,
|
||||||
|
): Partial<RoofSegmentNode> {
|
||||||
|
const nextSurfaceMaterial = { material, materialPreset }
|
||||||
|
const nextTop =
|
||||||
|
targetRole === 'top'
|
||||||
|
? nextSurfaceMaterial
|
||||||
|
: getEffectiveSegmentSurfaceMaterial(node, 'top')
|
||||||
|
const nextEdge =
|
||||||
|
targetRole === 'edge'
|
||||||
|
? nextSurfaceMaterial
|
||||||
|
: getEffectiveSegmentSurfaceMaterial(node, 'edge')
|
||||||
|
const nextWall =
|
||||||
|
targetRole === 'wall'
|
||||||
|
? nextSurfaceMaterial
|
||||||
|
: getEffectiveSegmentSurfaceMaterial(node, 'wall')
|
||||||
|
|
||||||
|
return {
|
||||||
|
topMaterial: nextTop.material,
|
||||||
|
topMaterialPreset: nextTop.materialPreset,
|
||||||
|
edgeMaterial: nextEdge.material,
|
||||||
|
edgeMaterialPreset: nextEdge.materialPreset,
|
||||||
|
wallMaterial: nextWall.material,
|
||||||
|
wallMaterialPreset: nextWall.materialPreset,
|
||||||
|
material: undefined,
|
||||||
|
materialPreset: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRoofSurfaceMaterialUpdates(
|
||||||
|
nodes: Record<string, AnyNode>,
|
||||||
|
node: RoofNode,
|
||||||
|
targetRole: RoofSurfaceMaterialRole,
|
||||||
|
material: MaterialSchema | undefined,
|
||||||
|
materialPreset: string | undefined,
|
||||||
|
): { id: AnyNodeId; data: Partial<AnyNode> }[] {
|
||||||
|
const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = [
|
||||||
|
{
|
||||||
|
id: node.id as AnyNodeId,
|
||||||
|
data: buildRoofSurfaceMaterialPatch(node, targetRole, material, materialPreset) as Partial<
|
||||||
|
AnyNode
|
||||||
|
>,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
if (targetRole !== 'top') return updates
|
||||||
|
|
||||||
|
for (const segmentId of node.children ?? []) {
|
||||||
|
const segment = nodes[segmentId as AnyNodeId]
|
||||||
|
if (segment?.type !== 'roof-segment') continue
|
||||||
|
updates.push({
|
||||||
|
id: segment.id as AnyNodeId,
|
||||||
|
data: { material, materialPreset } as Partial<RoofSegmentNode> as Partial<AnyNode>,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return updates
|
||||||
|
}
|
||||||
|
|
||||||
export function buildStairSurfaceMaterialPatch(
|
export function buildStairSurfaceMaterialPatch(
|
||||||
node: StairNode,
|
node: StairNode,
|
||||||
targetRole: StairSurfaceMaterialRole,
|
targetRole: StairSurfaceMaterialRole,
|
||||||
@@ -134,7 +226,14 @@ export function buildStairSurfaceMaterialPatch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildSingleSurfaceMaterialPatch<
|
export function buildSingleSurfaceMaterialPatch<
|
||||||
TNode extends FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode,
|
TNode extends
|
||||||
|
| FenceNode
|
||||||
|
| ColumnNode
|
||||||
|
| SlabNode
|
||||||
|
| CeilingNode
|
||||||
|
| ShelfNode
|
||||||
|
| BoxVentNode
|
||||||
|
| RidgeVentNode,
|
||||||
>(material: MaterialSchema | undefined, materialPreset: string | undefined): Partial<TNode> {
|
>(material: MaterialSchema | undefined, materialPreset: string | undefined): Partial<TNode> {
|
||||||
return {
|
return {
|
||||||
material,
|
material,
|
||||||
@@ -142,6 +241,29 @@ export function buildSingleSurfaceMaterialPatch<
|
|||||||
} as Partial<TNode>
|
} as Partial<TNode>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Chimney / dormer patch builders moved to
|
||||||
|
// `@pascal-app/nodes/<kind>/paint.ts` and are wired into the kind's
|
||||||
|
// `capabilities.paint.buildPatch`. The selection-manager invokes them
|
||||||
|
// through the registry; no editor-side helper needed here.
|
||||||
|
//
|
||||||
|
// `getEffectiveChimneyMaterial` below stays because
|
||||||
|
// `resolveActivePaintMaterialFromSelection` (also in this file) still
|
||||||
|
// has wall / roof / stair arms that follow the same shape — they all
|
||||||
|
// migrate together in a follow-up.
|
||||||
|
|
||||||
|
export function getEffectiveChimneyMaterial(
|
||||||
|
node: ChimneyNode,
|
||||||
|
role: ChimneyMaterialRole,
|
||||||
|
): { material: MaterialSchema | undefined; materialPreset: string | undefined } {
|
||||||
|
if (role === 'top') {
|
||||||
|
const hasTop = node.topMaterial !== undefined || node.topMaterialPreset !== undefined
|
||||||
|
if (hasTop) {
|
||||||
|
return { material: node.topMaterial, materialPreset: node.topMaterialPreset }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { material: node.material, materialPreset: node.materialPreset }
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveActivePaintMaterialFromSelection(params: {
|
export function resolveActivePaintMaterialFromSelection(params: {
|
||||||
nodes: Record<string, any>
|
nodes: Record<string, any>
|
||||||
selectedId: string | null
|
selectedId: string | null
|
||||||
@@ -151,6 +273,8 @@ export function resolveActivePaintMaterialFromSelection(params: {
|
|||||||
| WallSurfaceSide
|
| WallSurfaceSide
|
||||||
| StairSurfaceMaterialRole
|
| StairSurfaceMaterialRole
|
||||||
| RoofSurfaceMaterialRole
|
| RoofSurfaceMaterialRole
|
||||||
|
| ChimneyMaterialRole
|
||||||
|
| DormerSurfaceMaterialRole
|
||||||
| SingleSurfaceMaterialRole
|
| SingleSurfaceMaterialRole
|
||||||
} | null
|
} | null
|
||||||
}): ActivePaintMaterial | null {
|
}): ActivePaintMaterial | null {
|
||||||
@@ -161,22 +285,31 @@ export function resolveActivePaintMaterialFromSelection(params: {
|
|||||||
const selectedNode = nodes[selectedId]
|
const selectedNode = nodes[selectedId]
|
||||||
if (!selectedNode) return null
|
if (!selectedNode) return null
|
||||||
|
|
||||||
if (
|
// Registry-driven path. Kinds that declare
|
||||||
selectedNode.type === 'wall' &&
|
// `capabilities.paint.getEffectiveMaterial` resolve their effective
|
||||||
(selectedMaterialTarget.role === 'interior' || selectedMaterialTarget.role === 'exterior')
|
// material here without an editor-side per-kind arm. Wall,
|
||||||
) {
|
// chimney, dormer use this; roof / stair stay legacy below.
|
||||||
const surface = getEffectiveWallSurfaceMaterial(selectedNode, selectedMaterialTarget.role)
|
const paintCap = nodeRegistry.get(selectedNode.type)?.capabilities?.paint
|
||||||
return hasActivePaintMaterial({
|
if (paintCap?.getEffectiveMaterial) {
|
||||||
material: surface.material,
|
const surface = paintCap.getEffectiveMaterial({
|
||||||
materialPreset: surface.materialPreset,
|
node: selectedNode,
|
||||||
sourceTarget: 'wall',
|
role: selectedMaterialTarget.role as string,
|
||||||
|
nodes,
|
||||||
})
|
})
|
||||||
? {
|
if (surface) {
|
||||||
material: surface.material,
|
const sourceTarget = selectedNode.type as PaintableMaterialTarget
|
||||||
materialPreset: surface.materialPreset,
|
return hasActivePaintMaterial({
|
||||||
sourceTarget: 'wall',
|
material: surface.material,
|
||||||
}
|
materialPreset: surface.materialPreset,
|
||||||
: null
|
sourceTarget,
|
||||||
|
})
|
||||||
|
? {
|
||||||
|
material: surface.material,
|
||||||
|
materialPreset: surface.materialPreset,
|
||||||
|
sourceTarget,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -185,7 +318,27 @@ export function resolveActivePaintMaterialFromSelection(params: {
|
|||||||
selectedMaterialTarget.role === 'edge' ||
|
selectedMaterialTarget.role === 'edge' ||
|
||||||
selectedMaterialTarget.role === 'wall')
|
selectedMaterialTarget.role === 'wall')
|
||||||
) {
|
) {
|
||||||
const surface = getEffectiveRoofSurfaceMaterial(selectedNode, selectedMaterialTarget.role)
|
let surface = getEffectiveRoofSurfaceMaterial(selectedNode, selectedMaterialTarget.role)
|
||||||
|
if (
|
||||||
|
selectedMaterialTarget.role === 'top' &&
|
||||||
|
surface.material === undefined &&
|
||||||
|
surface.materialPreset === undefined
|
||||||
|
) {
|
||||||
|
const roofNode = selectedNode as RoofNode
|
||||||
|
const fallbackSegment = (roofNode.children ?? [])
|
||||||
|
.map((id: AnyNodeId) => nodes[id as AnyNodeId] as RoofSegmentNode | undefined)
|
||||||
|
.find(
|
||||||
|
(segment: RoofSegmentNode | undefined) =>
|
||||||
|
segment?.type === 'roof-segment' &&
|
||||||
|
(segment.material !== undefined || segment.materialPreset !== undefined),
|
||||||
|
)
|
||||||
|
if (fallbackSegment) {
|
||||||
|
surface = {
|
||||||
|
material: fallbackSegment.material,
|
||||||
|
materialPreset: fallbackSegment.materialPreset,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return hasActivePaintMaterial({
|
return hasActivePaintMaterial({
|
||||||
material: surface.material,
|
material: surface.material,
|
||||||
materialPreset: surface.materialPreset,
|
materialPreset: surface.materialPreset,
|
||||||
@@ -219,12 +372,17 @@ export function resolveActivePaintMaterialFromSelection(params: {
|
|||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wall / chimney / dormer flow through the registry-driven path
|
||||||
|
// at the top of this function.
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(selectedNode.type === 'fence' ||
|
(selectedNode.type === 'fence' ||
|
||||||
selectedNode.type === 'column' ||
|
selectedNode.type === 'column' ||
|
||||||
selectedNode.type === 'slab' ||
|
selectedNode.type === 'slab' ||
|
||||||
selectedNode.type === 'ceiling' ||
|
selectedNode.type === 'ceiling' ||
|
||||||
selectedNode.type === 'shelf') &&
|
selectedNode.type === 'shelf' ||
|
||||||
|
selectedNode.type === 'box-vent' ||
|
||||||
|
selectedNode.type === 'ridge-vent') &&
|
||||||
selectedMaterialTarget.role === 'surface'
|
selectedMaterialTarget.role === 'surface'
|
||||||
) {
|
) {
|
||||||
const target = selectedNode.type
|
const target = selectedNode.type
|
||||||
@@ -286,5 +444,21 @@ export function resolvePaintTargetFromSelection(params: {
|
|||||||
return 'shelf'
|
return 'shelf'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (selectedNode.type === 'chimney') {
|
||||||
|
return 'chimney'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedNode.type === 'dormer') {
|
||||||
|
return 'dormer'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedNode.type === 'box-vent') {
|
||||||
|
return 'box-vent'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedNode.type === 'ridge-vent') {
|
||||||
|
return 'ridge-vent'
|
||||||
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,12 @@ import {
|
|||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
type BuildingNode,
|
type BuildingNode,
|
||||||
type CeilingNode,
|
type CeilingNode,
|
||||||
|
type ChimneyMaterialRole,
|
||||||
|
type ChimneyNode,
|
||||||
type ColumnNode,
|
type ColumnNode,
|
||||||
type DoorNode,
|
type DoorNode,
|
||||||
|
type DormerNode,
|
||||||
|
type DormerSurfaceMaterialRole,
|
||||||
type ElevatorNode,
|
type ElevatorNode,
|
||||||
type FenceNode,
|
type FenceNode,
|
||||||
type ItemNode,
|
type ItemNode,
|
||||||
@@ -66,6 +70,12 @@ export type StructureTool =
|
|||||||
| 'window'
|
| 'window'
|
||||||
| 'door'
|
| 'door'
|
||||||
| 'shelf'
|
| 'shelf'
|
||||||
|
| 'box-vent'
|
||||||
|
| 'ridge-vent'
|
||||||
|
| 'chimney'
|
||||||
|
| 'solar-panel'
|
||||||
|
| 'skylight'
|
||||||
|
| 'dormer'
|
||||||
|
|
||||||
// Furnish mode tools (items and decoration)
|
// Furnish mode tools (items and decoration)
|
||||||
export type FurnishTool = 'item'
|
export type FurnishTool = 'item'
|
||||||
@@ -105,6 +115,8 @@ export type MaterialTargetRole =
|
|||||||
| WallSurfaceSide
|
| WallSurfaceSide
|
||||||
| StairSurfaceMaterialRole
|
| StairSurfaceMaterialRole
|
||||||
| RoofSurfaceMaterialRole
|
| RoofSurfaceMaterialRole
|
||||||
|
| ChimneyMaterialRole
|
||||||
|
| DormerSurfaceMaterialRole
|
||||||
| SingleSurfaceMaterialRole
|
| SingleSurfaceMaterialRole
|
||||||
|
|
||||||
export type SelectedMaterialTarget = {
|
export type SelectedMaterialTarget = {
|
||||||
@@ -142,7 +154,9 @@ type EditorState = {
|
|||||||
| DoorNode
|
| DoorNode
|
||||||
| ElevatorNode
|
| ElevatorNode
|
||||||
| CeilingNode
|
| CeilingNode
|
||||||
|
| ChimneyNode
|
||||||
| ColumnNode
|
| ColumnNode
|
||||||
|
| DormerNode
|
||||||
| SlabNode
|
| SlabNode
|
||||||
| WallNode
|
| WallNode
|
||||||
| FenceNode
|
| FenceNode
|
||||||
@@ -160,7 +174,9 @@ type EditorState = {
|
|||||||
| DoorNode
|
| DoorNode
|
||||||
| ElevatorNode
|
| ElevatorNode
|
||||||
| CeilingNode
|
| CeilingNode
|
||||||
|
| ChimneyNode
|
||||||
| ColumnNode
|
| ColumnNode
|
||||||
|
| DormerNode
|
||||||
| SlabNode
|
| SlabNode
|
||||||
| WallNode
|
| WallNode
|
||||||
| FenceNode
|
| FenceNode
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|||||||
import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema'
|
import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema'
|
||||||
import {
|
import {
|
||||||
CeilingNode,
|
CeilingNode,
|
||||||
|
getActiveRoofHeight,
|
||||||
LevelNode,
|
LevelNode,
|
||||||
RoofNode,
|
RoofNode,
|
||||||
RoofSegmentNode,
|
RoofSegmentNode,
|
||||||
@@ -52,7 +53,7 @@ export const createRoofInput = {
|
|||||||
width: z.number().positive(),
|
width: z.number().positive(),
|
||||||
depth: z.number().positive(),
|
depth: z.number().positive(),
|
||||||
roofType: z.enum(ROOF_TYPES).default('hip'),
|
roofType: z.enum(ROOF_TYPES).default('hip'),
|
||||||
roofHeight: z.number().positive().default(1.8),
|
pitch: z.number().min(0).max(85).default(35),
|
||||||
wallHeight: z.number().min(0).default(0.35),
|
wallHeight: z.number().min(0).default(0.35),
|
||||||
wallThickness: z.number().positive().default(0.16),
|
wallThickness: z.number().positive().default(0.16),
|
||||||
overhang: z.number().min(0).default(0.45),
|
overhang: z.number().min(0).default(0.45),
|
||||||
@@ -313,13 +314,16 @@ export function registerConstructionTools(server: McpServer, bridge: SceneOperat
|
|||||||
width,
|
width,
|
||||||
depth,
|
depth,
|
||||||
roofType,
|
roofType,
|
||||||
roofHeight,
|
pitch,
|
||||||
wallHeight,
|
wallHeight,
|
||||||
wallThickness,
|
wallThickness,
|
||||||
overhang,
|
overhang,
|
||||||
materialPreset,
|
materialPreset,
|
||||||
name,
|
name,
|
||||||
}) => {
|
}) => {
|
||||||
|
// Peak height is derived from pitch + footprint + type; we still
|
||||||
|
// need it to size the auto-generated roof level container below.
|
||||||
|
const peakHeight = getActiveRoofHeight({ roofType, pitch, width, depth })
|
||||||
const referenceLevel = assertNode(bridge, levelId, 'level')
|
const referenceLevel = assertNode(bridge, levelId, 'level')
|
||||||
const patches: Array<{ op: 'create'; node: AnyNode; parentId: AnyNodeId }> = []
|
const patches: Array<{ op: 'create'; node: AnyNode; parentId: AnyNodeId }> = []
|
||||||
let targetRoofLevelId = levelId as AnyNodeId
|
let targetRoofLevelId = levelId as AnyNodeId
|
||||||
@@ -343,7 +347,7 @@ export function registerConstructionTools(server: McpServer, bridge: SceneOperat
|
|||||||
role: 'roof',
|
role: 'roof',
|
||||||
label: roofLevelLabel,
|
label: roofLevelLabel,
|
||||||
referenceLevelId: levelId,
|
referenceLevelId: levelId,
|
||||||
height: roofLevelHeight ?? Math.max(wallHeight + roofHeight, 0.2),
|
height: roofLevelHeight ?? Math.max(wallHeight + peakHeight, 0.2),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
targetRoofLevelId = roofLevel.id as AnyNodeId
|
targetRoofLevelId = roofLevel.id as AnyNodeId
|
||||||
@@ -356,7 +360,7 @@ export function registerConstructionTools(server: McpServer, bridge: SceneOperat
|
|||||||
width,
|
width,
|
||||||
depth,
|
depth,
|
||||||
wallHeight,
|
wallHeight,
|
||||||
roofHeight,
|
pitch,
|
||||||
wallThickness,
|
wallThickness,
|
||||||
overhang,
|
overhang,
|
||||||
...(materialPreset ? { materialPreset } : {}),
|
...(materialPreset ? { materialPreset } : {}),
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import { buildBoxVentGeometry, computeBoxVentSlopeTilt } from '../geometry'
|
||||||
|
import { BoxVentNode } from '../schema'
|
||||||
|
|
||||||
|
describe('buildBoxVentGeometry', () => {
|
||||||
|
test('returns a non-empty BufferGeometry with position + normal + uv', () => {
|
||||||
|
const node = BoxVentNode.parse({})
|
||||||
|
const geo = buildBoxVentGeometry(node)
|
||||||
|
const positions = geo.getAttribute('position')
|
||||||
|
const normals = geo.getAttribute('normal')
|
||||||
|
const uvs = geo.getAttribute('uv')
|
||||||
|
expect(positions.count).toBeGreaterThan(0)
|
||||||
|
expect(normals.count).toBe(positions.count)
|
||||||
|
expect(uvs.count).toBe(positions.count)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('box style is two stacked rounded extrusions (riser + cover)', () => {
|
||||||
|
// Two rounded-rect extrusions (4 corners × 4 segs = 16 profile pts each).
|
||||||
|
// Per layer: 16 wall quads (96 verts) + 32 cap triangles (96 verts) = 192 verts.
|
||||||
|
// Two layers stacked → 384 verts total when bevel > 0.
|
||||||
|
const box = buildBoxVentGeometry(BoxVentNode.parse({ style: 'box' }))
|
||||||
|
expect(box.getAttribute('position').count).toBe(384)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('box style: zero bevel still produces a valid closed solid', () => {
|
||||||
|
// With bevel=0 the wall-edge dedupe drops the degenerate corner
|
||||||
|
// 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 }),
|
||||||
|
)
|
||||||
|
expect(box.getAttribute('position').count).toBeGreaterThan(0)
|
||||||
|
// Confirm the position attribute carries finite values only.
|
||||||
|
const positions = box.getAttribute('position').array as Float32Array
|
||||||
|
for (let i = 0; i < positions.length; i++) {
|
||||||
|
expect(Number.isFinite(positions[i])).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('cap style: body walls + flange + 4 chamfer faces + top (closed)', () => {
|
||||||
|
// 5 body quads (4 walls + bottom) + 1 flange + 4 chamfered faces +
|
||||||
|
// 1 flat top = 11 quads = 66 vertices. Confirms the cap is closed
|
||||||
|
// and uses the dedicated builder (not the dome fallback).
|
||||||
|
const cap = buildBoxVentGeometry(BoxVentNode.parse({ style: 'cap' }))
|
||||||
|
expect(cap.getAttribute('position').count).toBe(66)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('cap style: zero overhang drops the flange quad', () => {
|
||||||
|
const noFlange = buildBoxVentGeometry(
|
||||||
|
BoxVentNode.parse({ style: 'cap', hoodOverhang: 0 }),
|
||||||
|
)
|
||||||
|
// 10 quads × 6 vertices/quad = 60.
|
||||||
|
expect(noFlange.getAttribute('position').count).toBe(60)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('dome style still uses the unified dome+skirt geometry (Step 1)', () => {
|
||||||
|
const dome = buildBoxVentGeometry(BoxVentNode.parse({ style: 'dome' }))
|
||||||
|
expect(dome.getAttribute('position').count).toBeGreaterThan(60)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('legacy `standard` / `low-profile` style names migrate to new enum', () => {
|
||||||
|
expect(BoxVentNode.parse({ style: 'standard' }).style).toBe('cap')
|
||||||
|
expect(BoxVentNode.parse({ style: 'low-profile' }).style).toBe('box')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('low-profile reduces overall vent height proportionally', () => {
|
||||||
|
// Same node height but lower body share — total vertex count
|
||||||
|
// unchanged (same mesh topology) but the highest Y in positions
|
||||||
|
// is below the standard style's highest Y.
|
||||||
|
const standard = buildBoxVentGeometry(BoxVentNode.parse({ style: 'standard', height: 0.2 }))
|
||||||
|
const low = buildBoxVentGeometry(BoxVentNode.parse({ style: 'low-profile', height: 0.2 }))
|
||||||
|
const maxY = (geo: ReturnType<typeof buildBoxVentGeometry>) => {
|
||||||
|
const pos = geo.getAttribute('position').array as Float32Array
|
||||||
|
let m = -Infinity
|
||||||
|
for (let i = 1; i < pos.length; i += 3) if (pos[i]! > m) m = pos[i]!
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
// Total height is the same in both styles — height is the user-
|
||||||
|
// facing total. Body share differs but the hood compensates.
|
||||||
|
expect(maxY(standard)).toBeCloseTo(0.2)
|
||||||
|
expect(maxY(low)).toBeCloseTo(0.2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('width / depth control the footprint bounds', () => {
|
||||||
|
const node = BoxVentNode.parse({ width: 0.6, depth: 0.5, hoodOverhang: 0 })
|
||||||
|
const geo = buildBoxVentGeometry(node)
|
||||||
|
const pos = geo.getAttribute('position').array as Float32Array
|
||||||
|
let maxX = -Infinity
|
||||||
|
let maxZ = -Infinity
|
||||||
|
for (let i = 0; i < pos.length; i += 3) {
|
||||||
|
if (pos[i]! > maxX) maxX = pos[i]!
|
||||||
|
if (pos[i + 2]! > maxZ) maxZ = pos[i + 2]!
|
||||||
|
}
|
||||||
|
expect(maxX).toBeCloseTo(0.3)
|
||||||
|
expect(maxZ).toBeCloseTo(0.25)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('computeBoxVentSlopeTilt', () => {
|
||||||
|
// For a gable, getActiveRoofHeight = (depth/2) * tan(pitch). Picking
|
||||||
|
// these pitches makes the active roof height match the legacy fixtures.
|
||||||
|
const pitchForRise = (rise: number, depth: number) =>
|
||||||
|
(Math.atan2(rise, depth / 2) * 180) / Math.PI
|
||||||
|
|
||||||
|
test('flat segment returns 0 regardless of position', () => {
|
||||||
|
const seg = { roofType: 'flat' as const, pitch: 30, width: 6, depth: 6 }
|
||||||
|
expect(computeBoxVentSlopeTilt(seg, 2)).toBe(0)
|
||||||
|
expect(computeBoxVentSlopeTilt(seg, -2)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ridge (localZ=0) returns 0', () => {
|
||||||
|
expect(
|
||||||
|
computeBoxVentSlopeTilt(
|
||||||
|
{ roofType: 'gable', pitch: pitchForRise(2, 6), width: 6, depth: 6 },
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('positive Z tilts down by slope angle; negative Z tilts up by the same angle', () => {
|
||||||
|
const seg = {
|
||||||
|
roofType: 'gable' as const,
|
||||||
|
pitch: pitchForRise(2.5, 6),
|
||||||
|
width: 6,
|
||||||
|
depth: 6,
|
||||||
|
}
|
||||||
|
const expected = Math.atan2(2.5, 3)
|
||||||
|
expect(computeBoxVentSlopeTilt(seg, 1)).toBeCloseTo(expected)
|
||||||
|
expect(computeBoxVentSlopeTilt(seg, -1)).toBeCloseTo(-expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('undefined segment returns 0 (safe default before parent resolves)', () => {
|
||||||
|
expect(computeBoxVentSlopeTilt(undefined, 1)).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import { BoxVentNode } from '../schema'
|
||||||
|
|
||||||
|
describe('BoxVentNode schema', () => {
|
||||||
|
test('parses with sensible defaults', () => {
|
||||||
|
const parsed = BoxVentNode.parse({})
|
||||||
|
expect(parsed.type).toBe('box-vent')
|
||||||
|
expect(parsed.id).toMatch(/^bvent_/)
|
||||||
|
expect(parsed.width).toBe(0.4)
|
||||||
|
expect(parsed.depth).toBe(0.4)
|
||||||
|
expect(parsed.height).toBe(0.15)
|
||||||
|
expect(parsed.hoodOverhang).toBe(0.04)
|
||||||
|
expect(parsed.style).toBe('cap')
|
||||||
|
expect(parsed.position).toEqual([0, 0, 0])
|
||||||
|
expect(parsed.rotation).toBe(0)
|
||||||
|
expect(parsed.material).toBeUndefined()
|
||||||
|
expect(parsed.materialPreset).toBe('preset-white')
|
||||||
|
expect(parsed.roofSegmentId).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('accepts each style', () => {
|
||||||
|
expect(BoxVentNode.parse({ style: 'box' }).style).toBe('box')
|
||||||
|
expect(BoxVentNode.parse({ style: 'cap' }).style).toBe('cap')
|
||||||
|
expect(BoxVentNode.parse({ style: 'dome' }).style).toBe('dome')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects unknown style', () => {
|
||||||
|
expect(() => BoxVentNode.parse({ style: 'unknown' })).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('round-trips dimensions and segment binding', () => {
|
||||||
|
const parsed = BoxVentNode.parse({
|
||||||
|
width: 0.6,
|
||||||
|
depth: 0.5,
|
||||||
|
height: 0.2,
|
||||||
|
hoodOverhang: 0.08,
|
||||||
|
style: 'dome',
|
||||||
|
roofSegmentId: 'rseg_abc',
|
||||||
|
position: [1.2, 0, -0.5],
|
||||||
|
rotation: Math.PI / 4,
|
||||||
|
})
|
||||||
|
expect(parsed.width).toBe(0.6)
|
||||||
|
expect(parsed.depth).toBe(0.5)
|
||||||
|
expect(parsed.height).toBe(0.2)
|
||||||
|
expect(parsed.hoodOverhang).toBe(0.08)
|
||||||
|
expect(parsed.style).toBe('dome')
|
||||||
|
expect(parsed.roofSegmentId).toBe('rseg_abc')
|
||||||
|
expect(parsed.position).toEqual([1.2, 0, -0.5])
|
||||||
|
expect(parsed.rotation).toBeCloseTo(Math.PI / 4)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('generates unique IDs across calls', () => {
|
||||||
|
const a = BoxVentNode.parse({})
|
||||||
|
const b = BoxVentNode.parse({})
|
||||||
|
expect(a.id).not.toBe(b.id)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { type NodeDefinition, BoxVentNode as BoxVentNodeSchema } from '@pascal-app/core'
|
||||||
|
import { boxVentParametrics } from './parametrics'
|
||||||
|
import { BoxVentNode } from './schema'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Box vent — a small louvered ventilation box that sits on a roof
|
||||||
|
* slope. Parented to a `roof-segment`; position is segment-local;
|
||||||
|
* rotation rotates the vent around the segment's vertical axis after
|
||||||
|
* the slope tilt is applied.
|
||||||
|
*
|
||||||
|
* Composition (three-checkbox model):
|
||||||
|
* - **`renderer` (custom)** — the box-vent needs the parent segment's
|
||||||
|
* position + rotation + slope geometry to position itself, and the
|
||||||
|
* registry-era roof-segment renderer doesn't auto-nest children
|
||||||
|
* (its mesh is filled by `RoofSystem`). The custom renderer reads
|
||||||
|
* the segment from `useScene`, applies the transform stack, and
|
||||||
|
* follows the segment's `useLiveTransforms` override during a
|
||||||
|
* parent drag.
|
||||||
|
* - **no `geometry`** — geometry is created inside the renderer via
|
||||||
|
* the shared pure builder in `./geometry`. We could lift it to
|
||||||
|
* `def.geometry` once roof-segment migrates to the parametric path
|
||||||
|
* (Phase 5 Stage B); for now keeping it inside the renderer
|
||||||
|
* matches the legacy mount semantics one-for-one.
|
||||||
|
* - **no `system`** — no animations, no cross-kind cascades.
|
||||||
|
*
|
||||||
|
* The bespoke move flow (segment-hopping with hit-tests against every
|
||||||
|
* sibling roof-segment) ports later as `affordanceTools.move`. The
|
||||||
|
* placement `def.tool` listens to `roof:*` events and creates a new
|
||||||
|
* vent on click.
|
||||||
|
*/
|
||||||
|
export const boxVentDefinition: NodeDefinition<typeof BoxVentNode> = {
|
||||||
|
kind: 'box-vent',
|
||||||
|
schemaVersion: 1,
|
||||||
|
schema: BoxVentNode,
|
||||||
|
category: 'structure',
|
||||||
|
|
||||||
|
defaults: () => {
|
||||||
|
const stub = BoxVentNodeSchema.parse({ id: 'bvent_default' as never, type: 'box-vent' })
|
||||||
|
const { id: _id, type: _type, ...rest } = stub
|
||||||
|
return rest
|
||||||
|
},
|
||||||
|
|
||||||
|
capabilities: {
|
||||||
|
selectable: { hitVolume: 'bbox' },
|
||||||
|
duplicable: true,
|
||||||
|
deletable: true,
|
||||||
|
// Mounts on a roof segment via `roofSegmentId`. Sits ON TOP of the
|
||||||
|
// slope — no `buildCut`, just the dirty cascade so the parent
|
||||||
|
// roof's merged shell rebuilds when the vent moves / resizes.
|
||||||
|
roofAccessory: {},
|
||||||
|
},
|
||||||
|
|
||||||
|
parametrics: boxVentParametrics,
|
||||||
|
|
||||||
|
renderer: {
|
||||||
|
kind: 'parametric',
|
||||||
|
module: () => import('./renderer'),
|
||||||
|
},
|
||||||
|
|
||||||
|
preview: () => import('./preview'),
|
||||||
|
tool: () => import('./tool'),
|
||||||
|
affordanceTools: {
|
||||||
|
move: () => import('./move-tool'),
|
||||||
|
},
|
||||||
|
toolHints: [
|
||||||
|
{ key: 'Left click', label: 'Place box vent on roof' },
|
||||||
|
{ key: 'Esc', label: 'Cancel' },
|
||||||
|
],
|
||||||
|
|
||||||
|
presentation: {
|
||||||
|
label: 'Box Vent',
|
||||||
|
description: 'Small louvered exhaust vent that sits on a roof slope.',
|
||||||
|
icon: { kind: 'url', src: '/icons/roof.png' },
|
||||||
|
paletteSection: 'structure',
|
||||||
|
paletteOrder: 120,
|
||||||
|
},
|
||||||
|
|
||||||
|
mcp: {
|
||||||
|
description:
|
||||||
|
'A louvered box vent sitting on a roof segment. Style: standard / low-profile / dome. Width/depth/height/hoodOverhang parametric.',
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,526 @@
|
|||||||
|
import { type BoxVentNode, getActiveRoofHeight, type RoofType } from '@pascal-app/core'
|
||||||
|
import * as THREE from 'three'
|
||||||
|
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure builder for the box-vent mesh. Models a real attic box vent:
|
||||||
|
*
|
||||||
|
* ┌──────────────────────┐ ← rounded dome cap (closed)
|
||||||
|
* │ ─── │
|
||||||
|
* │ ◜─────────────◝ │
|
||||||
|
* ─┘─────────────────────└─ ← flange flashing
|
||||||
|
*
|
||||||
|
* - **Body**: short rectangular walls + a sealed bottom.
|
||||||
|
* - **Dome cap**: smooth half-ellipsoid that fully closes the top — no
|
||||||
|
* flat plateau (the old pyramid hood left one). Used for every style;
|
||||||
|
* `style` just tunes how much of the total height is body vs cap.
|
||||||
|
* - **Skirt / flange**: the dome's base ring extends past the body by
|
||||||
|
* `hoodOverhang`, doubling as the mounting flashing tab.
|
||||||
|
*
|
||||||
|
* Louvered slats were removed — real box vents read smooth from typical
|
||||||
|
* camera distances; the slat pile only made the ghost preview noisy and
|
||||||
|
* the texture wrap unpredictable.
|
||||||
|
*
|
||||||
|
* Pure: no React, no scene access, no store mutation. Safe to call from
|
||||||
|
* unit tests, the placement preview, and the move-tool ghost.
|
||||||
|
*/
|
||||||
|
export function buildBoxVentGeometry(node: BoxVentNode): THREE.BufferGeometry {
|
||||||
|
if (node.style === 'box') return buildBoxShape(node)
|
||||||
|
if (node.style === 'cap') return buildCapShape(node)
|
||||||
|
// `dome` will get its own dedicated builder in Step 3. For now it
|
||||||
|
// keeps the unified dome+skirt shape so the visual doesn't regress.
|
||||||
|
return buildDomeStyleShape(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Box style ───────────────────────────────────────────────────────
|
||||||
|
// Two stacked rounded-corner boxes — a smaller riser at the base and a
|
||||||
|
// larger cover on top — reads as a residential attic-vent housing:
|
||||||
|
//
|
||||||
|
// ┌───────────────────────────┐ ← top cover (w × d)
|
||||||
|
// │ │
|
||||||
|
// │ │
|
||||||
|
// └────┐ ┌────┘
|
||||||
|
// │ │ ← riser (inset by baseInset)
|
||||||
|
// └─────────────────┘
|
||||||
|
//
|
||||||
|
// Both layers are extruded rounded rectangles so the vertical corners
|
||||||
|
// pick up the `cornerBevel`, giving a softer, more product-like silhou-
|
||||||
|
// ette than the old single hard-edged box.
|
||||||
|
|
||||||
|
const BOX_CORNER_SEGS = 4
|
||||||
|
|
||||||
|
function buildBoxShape(node: BoxVentNode): THREE.BufferGeometry {
|
||||||
|
// Schema defaults only fire on parse; older nodes in the store may
|
||||||
|
// not carry these fields. Fall back so the maths can never go NaN.
|
||||||
|
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 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)
|
||||||
|
const cornerBevel = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(node.cornerBevel ?? 0.012, Math.min(baseW, baseD) / 2 - 0.001),
|
||||||
|
)
|
||||||
|
|
||||||
|
const positions: number[] = []
|
||||||
|
const normals: number[] = []
|
||||||
|
const uvs: number[] = []
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
)
|
||||||
|
// 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
return buildBufferGeometry(positions, normals, uvs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extruded rounded rectangle: walls follow a rounded-rect profile,
|
||||||
|
// top + bottom caps are fan-triangulated from the centroid. Both caps
|
||||||
|
// are always included — overlap with adjacent geometry is invisible.
|
||||||
|
function buildRoundedExtrusion(
|
||||||
|
positions: number[],
|
||||||
|
normals: number[],
|
||||||
|
uvs: number[],
|
||||||
|
w: number,
|
||||||
|
d: number,
|
||||||
|
y0: number,
|
||||||
|
y1: number,
|
||||||
|
bevel: number,
|
||||||
|
): void {
|
||||||
|
const profile = roundedRectProfile(w, d, bevel, BOX_CORNER_SEGS)
|
||||||
|
const n = profile.length
|
||||||
|
|
||||||
|
// Walls: each edge in the closed profile becomes an outward-facing quad.
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const a = profile[i]!
|
||||||
|
const b = profile[(i + 1) % n]!
|
||||||
|
const ex = b.x - a.x
|
||||||
|
const ez = b.z - a.z
|
||||||
|
const len = Math.sqrt(ex * ex + ez * ez)
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top cap (+Y normal): wind triangles CW from above so the cross
|
||||||
|
// product points up. See pushTri's comment for the orientation note.
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2D rounded-rect profile in the XZ plane, traced CCW from above.
|
||||||
|
// `segsPerCorner` controls the corner smoothness — points are deduped
|
||||||
|
// per corner so adjacent corners share a clean tangent at the join.
|
||||||
|
function roundedRectProfile(
|
||||||
|
w: number,
|
||||||
|
d: number,
|
||||||
|
bevel: number,
|
||||||
|
segsPerCorner: number,
|
||||||
|
): Array<{ x: number; z: number }> {
|
||||||
|
const hw = w / 2
|
||||||
|
const hd = d / 2
|
||||||
|
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: Math.PI * 1.5 }, // SE
|
||||||
|
]
|
||||||
|
const out: Array<{ x: number; z: number }> = []
|
||||||
|
for (const c of corners) {
|
||||||
|
// Skip the last sample of each corner — it duplicates the first
|
||||||
|
// sample of the next corner.
|
||||||
|
for (let k = 0; k < segsPerCorner; k++) {
|
||||||
|
const t = k / segsPerCorner
|
||||||
|
const angle = c.startAngle + t * (Math.PI / 2)
|
||||||
|
out.push({ x: c.cx + r * Math.cos(angle), z: c.cz + r * Math.sin(angle) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Cap style ───────────────────────────────────────────────────────
|
||||||
|
// Body walls topped by a chamfered truncated-pyramid cap. The cap base
|
||||||
|
// matches the body's footprint plus `hoodOverhang` (small flare), and
|
||||||
|
// narrows to a smaller flat top driven by `topTaper`. The chamfer angle
|
||||||
|
// is the geometric consequence of `capHeight` × `topTaper` — adjusting
|
||||||
|
// either one bends the slope steeper or shallower:
|
||||||
|
//
|
||||||
|
// ┌─────┐ ← flat top (topTaper > 0)
|
||||||
|
// ╱ ╲
|
||||||
|
// ╱ ╲ ← chamfered cap (capHeight tall)
|
||||||
|
// ┌──────────────┐ ← cap base = body + overhang
|
||||||
|
// │ │
|
||||||
|
// │ body │ ← body (height − capHeight)
|
||||||
|
// │ │
|
||||||
|
// └──────────────┘
|
||||||
|
|
||||||
|
function buildCapShape(node: BoxVentNode): THREE.BufferGeometry {
|
||||||
|
const w = node.width
|
||||||
|
const d = node.depth
|
||||||
|
const h = node.height
|
||||||
|
// `??` guards legacy scene data — nodes saved before these fields
|
||||||
|
// existed don't carry them, and the schema default only fires at
|
||||||
|
// parse time (not on objects already in the store). Without these
|
||||||
|
// fallbacks the arithmetic below produced NaN positions and broke
|
||||||
|
// the bounding-sphere pass.
|
||||||
|
const overhang = node.hoodOverhang ?? 0.04
|
||||||
|
const topTaper = clamp01(node.topTaper ?? 0.4)
|
||||||
|
// Reserve at least 5mm each for body + cap so neither collapses.
|
||||||
|
const minSliver = 0.005
|
||||||
|
const rawGap = Math.max(0, node.capGap ?? 0)
|
||||||
|
const rawCapH = Math.max(minSliver, node.capHeight ?? 0.07)
|
||||||
|
// Distribute the available `height` between body / gap / cap. If the
|
||||||
|
// user dials the gap + cap past the total, shrink the gap first
|
||||||
|
// (preserves the visible cap shape) and then the cap as a last resort.
|
||||||
|
const maxBodyless = h - 2 * minSliver
|
||||||
|
const capH = Math.min(rawCapH, Math.max(minSliver, maxBodyless))
|
||||||
|
const capGap = Math.min(rawGap, Math.max(0, maxBodyless - capH))
|
||||||
|
const bodyH = h - capH - capGap
|
||||||
|
|
||||||
|
const hw = w / 2
|
||||||
|
const hd = d / 2
|
||||||
|
// Cap base extends past the body by `overhang` (flare). Top is the
|
||||||
|
// body's footprint scaled by `1 - topTaper`.
|
||||||
|
const bw = hw + overhang
|
||||||
|
const bd = hd + overhang
|
||||||
|
const tw = hw * (1 - topTaper)
|
||||||
|
const td = hd * (1 - topTaper)
|
||||||
|
|
||||||
|
// Cap floats `capGap` above the body. When the gap is zero the cap
|
||||||
|
// sits flush on the body and the body's top is hidden by the cap, so
|
||||||
|
// we skip the top face. When the gap is non-zero, close the body's
|
||||||
|
// top so you can't see inside through the slot.
|
||||||
|
const y0 = bodyH + capGap
|
||||||
|
const y1 = h
|
||||||
|
|
||||||
|
const positions: number[] = []
|
||||||
|
const normals: number[] = []
|
||||||
|
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])
|
||||||
|
|
||||||
|
// ── 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])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4 chamfered cap faces (trapezoids: wider at base, narrow at top).
|
||||||
|
// Normals point outward and upward (the slope direction). They're
|
||||||
|
// computed from the slope vector to get accurate shading.
|
||||||
|
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])
|
||||||
|
// -X face
|
||||||
|
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])
|
||||||
|
// -Z face
|
||||||
|
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])
|
||||||
|
|
||||||
|
return buildBufferGeometry(positions, normals, uvs)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp01(value: number): number {
|
||||||
|
return value < 0 ? 0 : value > 1 ? 1 : value
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Dome style (current implementation) ─────────────────────────────
|
||||||
|
// Body + dome cap with flange skirt. Drives the `dome` style until
|
||||||
|
// Step 3 swaps it for a dedicated builder.
|
||||||
|
|
||||||
|
function buildDomeStyleShape(node: BoxVentNode): THREE.BufferGeometry {
|
||||||
|
const w = node.width
|
||||||
|
const d = node.depth
|
||||||
|
const h = node.height
|
||||||
|
// Dome has no flange — the cap rolls down flush to the body footprint.
|
||||||
|
// `hoodOverhang` is hidden from the panel for this style; we ignore any
|
||||||
|
// stored value so legacy nodes still render flush.
|
||||||
|
const overhang = 0
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Body ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function buildBody(w: number, d: number, bodyH: number): THREE.BufferGeometry {
|
||||||
|
const hw = w / 2
|
||||||
|
const hd = d / 2
|
||||||
|
const positions: number[] = []
|
||||||
|
const normals: number[] = []
|
||||||
|
const uvs: number[] = []
|
||||||
|
|
||||||
|
// +X side
|
||||||
|
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])
|
||||||
|
// +Z side
|
||||||
|
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])
|
||||||
|
// 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])
|
||||||
|
|
||||||
|
return buildBufferGeometry(positions, normals, uvs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Dome hood ───────────────────────────────────────────────────────
|
||||||
|
// Closed rounded cap (half-ellipsoid sampled on a lat × lng grid) plus
|
||||||
|
// a flat skirt that extends past the body by `overhang` — that skirt is
|
||||||
|
// what reads as the flashing flange in the reference photo. The cap is
|
||||||
|
// fully closed at the apex (single pole vertex), so there's no empty
|
||||||
|
// plateau like the old pyramid hood had.
|
||||||
|
//
|
||||||
|
// `style` shifts the dome shape subtly:
|
||||||
|
// - 'standard' → moderate dome, gentle roll-off near the apex
|
||||||
|
// - 'low-profile' → very shallow dome (mostly a curved pillow)
|
||||||
|
// - 'dome' → near-hemisphere with sharper apex curvature
|
||||||
|
|
||||||
|
function buildDomeHood(
|
||||||
|
w: number,
|
||||||
|
d: number,
|
||||||
|
overhang: number,
|
||||||
|
bodyH: number,
|
||||||
|
hoodH: number,
|
||||||
|
style: BoxVentNode['style'],
|
||||||
|
): THREE.BufferGeometry {
|
||||||
|
const positions: number[] = []
|
||||||
|
const normals: number[] = []
|
||||||
|
const uvs: number[] = []
|
||||||
|
|
||||||
|
const bw = w / 2 + overhang
|
||||||
|
const bd = d / 2 + overhang
|
||||||
|
const y0 = bodyH
|
||||||
|
|
||||||
|
// Skirt underside
|
||||||
|
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
|
||||||
|
// longer near the top (soft pillow silhouette, like the reference
|
||||||
|
// photo). `dome` uses a true ellipsoid; `cap` defaults to a softer
|
||||||
|
// pillow until Step 2 swaps it for the pyramid hood.
|
||||||
|
const radialPower = style === 'dome' ? 1.0 : 0.65
|
||||||
|
const lat = 6
|
||||||
|
const lng = 14
|
||||||
|
const points: THREE.Vector3[][] = []
|
||||||
|
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 y = y0 + hoodH * Math.sin(phi)
|
||||||
|
for (let j = 0; j <= lng; j++) {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
points.push(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ab = new THREE.Vector3()
|
||||||
|
const ad = new THREE.Vector3()
|
||||||
|
for (let i = 0; i < lat; i++) {
|
||||||
|
for (let j = 0; j < lng; j++) {
|
||||||
|
const a = points[i]![j]!
|
||||||
|
const b = points[i]![j + 1]!
|
||||||
|
const c = points[i + 1]![j + 1]!
|
||||||
|
const d2 = points[i + 1]![j]!
|
||||||
|
ab.subVectors(b, a)
|
||||||
|
ad.subVectors(d2, a)
|
||||||
|
// Outward dome normal: `ad × ab` matches pushQuad's `(a,c,b)+(a,d,c)`
|
||||||
|
// 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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildBufferGeometry(positions, normals, uvs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function buildBufferGeometry(
|
||||||
|
positions: number[],
|
||||||
|
normals: number[],
|
||||||
|
uvs: number[],
|
||||||
|
): THREE.BufferGeometry {
|
||||||
|
const geo = new THREE.BufferGeometry()
|
||||||
|
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
|
||||||
|
geo.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3))
|
||||||
|
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
|
||||||
|
return geo
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushQuad(
|
||||||
|
positions: number[],
|
||||||
|
normals: number[],
|
||||||
|
uvs: number[],
|
||||||
|
a: number[],
|
||||||
|
b: number[],
|
||||||
|
c: number[],
|
||||||
|
d: number[],
|
||||||
|
n: number[],
|
||||||
|
) {
|
||||||
|
const nLen = Math.sqrt(n[0]! * n[0]! + n[1]! * n[1]! + n[2]! * n[2]!) || 1
|
||||||
|
const nx = n[0]! / nLen
|
||||||
|
const ny = n[1]! / nLen
|
||||||
|
const nz = n[2]! / nLen
|
||||||
|
|
||||||
|
// Dimension-based planar UVs: U follows |b-a| (the quad's "right"
|
||||||
|
// edge) and V follows |d-a| ("up"). Textures then tile at world
|
||||||
|
// scale across every face — a 0.4m vent face uses 0.4 UV units, not
|
||||||
|
// a fixed 0..1 — so a brick / metal / shingle preset reads at a
|
||||||
|
// consistent density on the body, hood, and louvers.
|
||||||
|
const abx = b[0]! - a[0]!
|
||||||
|
const aby = b[1]! - a[1]!
|
||||||
|
const abz = b[2]! - a[2]!
|
||||||
|
const adx = d[0]! - a[0]!
|
||||||
|
const ady = d[1]! - a[1]!
|
||||||
|
const adz = d[2]! - a[2]!
|
||||||
|
const u = Math.sqrt(abx * abx + aby * aby + abz * abz)
|
||||||
|
const v = Math.sqrt(adx * adx + ady * ady + adz * adz)
|
||||||
|
|
||||||
|
// Winding is (a, c, b) + (a, d, c) so the triangle face direction
|
||||||
|
// matches the stored normal (see earlier note on the dark-shading
|
||||||
|
// regression this fixed).
|
||||||
|
positions.push(a[0]!, a[1]!, a[2]!, c[0]!, c[1]!, c[2]!, b[0]!, b[1]!, b[2]!)
|
||||||
|
normals.push(nx, ny, nz, nx, ny, nz, nx, ny, nz)
|
||||||
|
uvs.push(0, 0, u, v, u, 0)
|
||||||
|
positions.push(a[0]!, a[1]!, a[2]!, d[0]!, d[1]!, d[2]!, c[0]!, c[1]!, c[2]!)
|
||||||
|
normals.push(nx, ny, nz, nx, ny, nz, nx, ny, nz)
|
||||||
|
uvs.push(0, 0, 0, v, u, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushTri: single-triangle counterpart to pushQuad. Caller orders (a, b, c)
|
||||||
|
// so that (b-a) × (c-a) points in the same direction as the stored
|
||||||
|
// normal `n` — same dark-shading-fix convention as pushQuad. UVs are
|
||||||
|
// dimension-based (length of the two sides from a).
|
||||||
|
function pushTri(
|
||||||
|
positions: number[],
|
||||||
|
normals: number[],
|
||||||
|
uvs: number[],
|
||||||
|
a: number[],
|
||||||
|
b: number[],
|
||||||
|
c: number[],
|
||||||
|
n: number[],
|
||||||
|
) {
|
||||||
|
const nLen = Math.sqrt(n[0]! * n[0]! + n[1]! * n[1]! + n[2]! * n[2]!) || 1
|
||||||
|
const nx = n[0]! / nLen
|
||||||
|
const ny = n[1]! / nLen
|
||||||
|
const nz = n[2]! / nLen
|
||||||
|
|
||||||
|
const abx = b[0]! - a[0]!
|
||||||
|
const aby = b[1]! - a[1]!
|
||||||
|
const abz = b[2]! - a[2]!
|
||||||
|
const acx = c[0]! - a[0]!
|
||||||
|
const acy = c[1]! - a[1]!
|
||||||
|
const acz = c[2]! - a[2]!
|
||||||
|
const u = Math.sqrt(abx * abx + aby * aby + abz * abz)
|
||||||
|
const v = Math.sqrt(acx * acx + acy * acy + acz * acz)
|
||||||
|
|
||||||
|
positions.push(a[0]!, a[1]!, a[2]!, b[0]!, b[1]!, b[2]!, c[0]!, c[1]!, c[2]!)
|
||||||
|
normals.push(nx, ny, nz, nx, ny, nz, nx, ny, nz)
|
||||||
|
uvs.push(0, 0, u, 0, 0, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slope tilt for a box-vent at segment-local Z position. The vent's
|
||||||
|
* X axis stays parallel to the segment's ridge; the +Z (down-slope)
|
||||||
|
* side dips, the -Z (up-slope) side lifts. Flat segments return 0.
|
||||||
|
*
|
||||||
|
* Pure: lifted out so the renderer / move tool / preview share one
|
||||||
|
* source of truth.
|
||||||
|
*/
|
||||||
|
export function computeBoxVentSlopeTilt(
|
||||||
|
segment:
|
||||||
|
| { roofType: RoofType; pitch: number; width: number; depth: number }
|
||||||
|
| undefined,
|
||||||
|
localZ: number,
|
||||||
|
): number {
|
||||||
|
if (!segment || segment.roofType === 'flat' || localZ === 0) return 0
|
||||||
|
const rh = getActiveRoofHeight(segment)
|
||||||
|
const slopeAngle = Math.atan2(rh, segment.depth / 2)
|
||||||
|
return localZ > 0 ? slopeAngle : -slopeAngle
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { boxVentDefinition } from './definition'
|
||||||
|
export { buildBoxVentGeometry, computeBoxVentSlopeTilt } from './geometry'
|
||||||
|
export { BoxVentNode } from './schema'
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import {
|
||||||
|
type AnyNodeId,
|
||||||
|
type BoxVentNode,
|
||||||
|
emitter,
|
||||||
|
type RoofEvent,
|
||||||
|
type RoofNode,
|
||||||
|
type RoofSegmentNode,
|
||||||
|
sceneRegistry,
|
||||||
|
useScene,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||||
|
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 BoxVentPreview from './preview'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Box-vent move tool. Mirrors the placement tool's cursor behaviour
|
||||||
|
* (ghost follows the roof surface; click on a roof commits) but for an
|
||||||
|
* already-existing vent: the original mesh is hidden during the drag,
|
||||||
|
* the ghost tracks the cursor with the correct slope tilt + segment yaw,
|
||||||
|
* and the click updates the node's position + parent segment in one
|
||||||
|
* undoable step. Cancel restores the original transform; if the node was
|
||||||
|
* freshly cloned (`metadata.isNew`), cancel deletes it instead.
|
||||||
|
*/
|
||||||
|
export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) {
|
||||||
|
const exitMoveMode = useCallback(() => {
|
||||||
|
useEditor.getState().setMovingNode(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const [previewPos, setPreviewPos] = useState<[number, number, number] | null>(null)
|
||||||
|
const [previewSurfaceQuat, setPreviewSurfaceQuat] =
|
||||||
|
useState<THREE.Quaternion | null>(null)
|
||||||
|
const [previewYaw, setPreviewYaw] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
useScene.temporal.getState().pause()
|
||||||
|
|
||||||
|
const original = {
|
||||||
|
position: [...node.position] as [number, number, number],
|
||||||
|
rotation: node.rotation ?? 0,
|
||||||
|
roofSegmentId: node.roofSegmentId,
|
||||||
|
parentId: node.parentId,
|
||||||
|
metadata: node.metadata,
|
||||||
|
}
|
||||||
|
const meta =
|
||||||
|
typeof node.metadata === 'object' && node.metadata !== null
|
||||||
|
? (node.metadata as Record<string, unknown>)
|
||||||
|
: {}
|
||||||
|
const isNew = !!meta.isNew
|
||||||
|
|
||||||
|
const ventObj = sceneRegistry.nodes.get(node.id)
|
||||||
|
if (ventObj) ventObj.visible = false
|
||||||
|
|
||||||
|
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) return [wx, wy, wz]
|
||||||
|
const v = new THREE.Vector3(wx, wy, wz)
|
||||||
|
buildingObj.worldToLocal(v)
|
||||||
|
return [v.x, v.y, v.z]
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastSnap: [number, number] | null = null
|
||||||
|
|
||||||
|
const updatePreview = (event: RoofEvent) => {
|
||||||
|
const wx = event.position[0]
|
||||||
|
const wy = event.position[1]
|
||||||
|
const wz = event.position[2]
|
||||||
|
|
||||||
|
const sx = Math.round(wx * 20) / 20
|
||||||
|
const sz = Math.round(wz * 20) / 20
|
||||||
|
if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) {
|
||||||
|
triggerSFX('sfx:grid-snap')
|
||||||
|
lastSnap = [sx, sz]
|
||||||
|
}
|
||||||
|
|
||||||
|
const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz)
|
||||||
|
if (!hit) return
|
||||||
|
|
||||||
|
const normal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment)
|
||||||
|
setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion()))
|
||||||
|
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0))
|
||||||
|
setPreviewPos(worldToBuildingLocal(wx, wy, wz))
|
||||||
|
event.stopPropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onRoofClick = (event: RoofEvent) => {
|
||||||
|
const hit = resolveRoofSegmentHit(
|
||||||
|
event.node as RoofNode,
|
||||||
|
event.position[0],
|
||||||
|
event.position[1],
|
||||||
|
event.position[2],
|
||||||
|
)
|
||||||
|
if (!hit) return
|
||||||
|
const targetSegmentId = hit.segment.id as AnyNodeId
|
||||||
|
const st = useScene.getState()
|
||||||
|
|
||||||
|
// Reparent if the cursor landed on a different segment than the
|
||||||
|
// node currently belongs to. Mirrors the skylight move flow:
|
||||||
|
// remove the node id from the old segment's children, append to
|
||||||
|
// the new one, mark both dirty so the merged-roof system rebuilds.
|
||||||
|
const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined
|
||||||
|
if (prevSegmentId && prevSegmentId !== targetSegmentId) {
|
||||||
|
const oldSeg = st.nodes[prevSegmentId] as RoofSegmentNode | undefined
|
||||||
|
if (oldSeg) {
|
||||||
|
st.updateNode(prevSegmentId, {
|
||||||
|
children: (oldSeg.children ?? []).filter((id) => id !== node.id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const newSeg = st.nodes[targetSegmentId] as RoofSegmentNode | undefined
|
||||||
|
if (newSeg && !(newSeg.children ?? []).includes(node.id)) {
|
||||||
|
st.updateNode(targetSegmentId, {
|
||||||
|
children: [...(newSeg.children ?? []), node.id],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
st.dirtyNodes.add(prevSegmentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
useScene.temporal.getState().resume()
|
||||||
|
st.updateNode(node.id as AnyNodeId, {
|
||||||
|
roofSegmentId: targetSegmentId,
|
||||||
|
parentId: targetSegmentId,
|
||||||
|
position: [hit.localX, hit.localY, hit.localZ],
|
||||||
|
rotation: original.rotation,
|
||||||
|
visible: true,
|
||||||
|
metadata: {},
|
||||||
|
})
|
||||||
|
useScene.temporal.getState().pause()
|
||||||
|
|
||||||
|
st.dirtyNodes.add(targetSegmentId)
|
||||||
|
st.dirtyNodes.add(node.id as AnyNodeId)
|
||||||
|
|
||||||
|
const obj = sceneRegistry.nodes.get(node.id)
|
||||||
|
if (obj) obj.visible = true
|
||||||
|
|
||||||
|
triggerSFX('sfx:item-place')
|
||||||
|
exitMoveMode()
|
||||||
|
event.stopPropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCancel = () => {
|
||||||
|
if (isNew) {
|
||||||
|
// Freshly-cloned vent — undo the clone entirely on cancel so the
|
||||||
|
// 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
|
||||||
|
if (parent) {
|
||||||
|
useScene.getState().updateNode(parentId, {
|
||||||
|
children: (parent.children ?? []).filter((id) => id !== node.id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
useScene.getState().deleteNode(node.id as AnyNodeId)
|
||||||
|
useScene.temporal.getState().resume()
|
||||||
|
markToolCancelConsumed()
|
||||||
|
exitMoveMode()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
useScene.getState().updateNode(node.id as AnyNodeId, {
|
||||||
|
position: original.position,
|
||||||
|
rotation: original.rotation,
|
||||||
|
roofSegmentId: original.roofSegmentId as AnyNodeId | undefined,
|
||||||
|
parentId: original.parentId as AnyNodeId | undefined,
|
||||||
|
metadata: original.metadata,
|
||||||
|
})
|
||||||
|
if (original.roofSegmentId) {
|
||||||
|
useScene.getState().dirtyNodes.add(original.roofSegmentId as AnyNodeId)
|
||||||
|
}
|
||||||
|
const obj = sceneRegistry.nodes.get(node.id)
|
||||||
|
if (obj) obj.visible = true
|
||||||
|
|
||||||
|
useScene.temporal.getState().resume()
|
||||||
|
markToolCancelConsumed()
|
||||||
|
exitMoveMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
emitter.on('roof:move', updatePreview)
|
||||||
|
emitter.on('roof:enter', updatePreview)
|
||||||
|
emitter.on('roof:click', onRoofClick)
|
||||||
|
emitter.on('tool:cancel', onCancel)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
emitter.off('roof:move', updatePreview)
|
||||||
|
emitter.off('roof:enter', updatePreview)
|
||||||
|
emitter.off('roof:click', onRoofClick)
|
||||||
|
emitter.off('tool:cancel', onCancel)
|
||||||
|
|
||||||
|
// Safety restore — if the tool is unmounted by something other than
|
||||||
|
// a commit / cancel path (e.g. tool change, selection wipe), leave
|
||||||
|
// the original mesh visible rather than stranded invisible.
|
||||||
|
const obj = sceneRegistry.nodes.get(node.id)
|
||||||
|
if (obj) obj.visible = true
|
||||||
|
useScene.temporal.getState().resume()
|
||||||
|
}
|
||||||
|
}, [exitMoveMode, node])
|
||||||
|
|
||||||
|
if (!(previewPos && previewSurfaceQuat)) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group position={previewPos}>
|
||||||
|
<group rotation-y={previewYaw}>
|
||||||
|
<group quaternion={previewSurfaceQuat}>
|
||||||
|
<BoxVentPreview node={node} />
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import {
|
||||||
|
type AnyNode,
|
||||||
|
type AnyNodeId,
|
||||||
|
BoxVentNode as BoxVentSchema,
|
||||||
|
getActiveRoofHeight,
|
||||||
|
type RoofSegmentNode,
|
||||||
|
useLiveNodeOverrides,
|
||||||
|
useScene,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import type { BoxVentNode } from './schema'
|
||||||
|
import {
|
||||||
|
ActionButton,
|
||||||
|
ActionGroup,
|
||||||
|
PanelSection,
|
||||||
|
PanelWrapper,
|
||||||
|
SegmentedControl,
|
||||||
|
SliderControl,
|
||||||
|
triggerSFX,
|
||||||
|
useEditor,
|
||||||
|
} from '@pascal-app/editor'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { Copy, Move, Trash2 } from 'lucide-react'
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inspector panel for a placed box-vent. Exposes the same parametrics
|
||||||
|
* as the auto-derived inspector (style + dimensions) plus Move /
|
||||||
|
* Duplicate / Delete actions wired into the same ghost-preview drag
|
||||||
|
* flow the placement tool uses.
|
||||||
|
*
|
||||||
|
* Move sets the vent as `editor.movingNode` — the registered move
|
||||||
|
* affordance tool (see `./move-tool.tsx`) takes over from there.
|
||||||
|
* Duplicate inserts a fresh clone into the scene (marked `isNew` so a
|
||||||
|
* cancelled drag deletes it), then routes the same way. On click the
|
||||||
|
* ghost commits; on Esc it cancels and the original mesh is restored.
|
||||||
|
*/
|
||||||
|
export default function BoxVentPanel() {
|
||||||
|
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||||
|
const setSelection = useViewer((s) => s.setSelection)
|
||||||
|
const updateNode = useScene((s) => s.updateNode)
|
||||||
|
const deleteNode = useScene((s) => s.deleteNode)
|
||||||
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||||
|
|
||||||
|
const storeNode = useScene((s) =>
|
||||||
|
selectedId ? (s.nodes[selectedId as AnyNode['id']] as BoxVentNode | undefined) : undefined,
|
||||||
|
)
|
||||||
|
// Mirror any in-progress slider drag so the panel reads the same live
|
||||||
|
// values the renderer paints. Without this the slider thumb would jump
|
||||||
|
// back to the store value on every render until commit.
|
||||||
|
const overrides = useLiveNodeOverrides((s) =>
|
||||||
|
selectedId ? (s.get(selectedId as AnyNodeId) as Partial<BoxVentNode> | undefined) : undefined,
|
||||||
|
)
|
||||||
|
const node: BoxVentNode | undefined =
|
||||||
|
storeNode && overrides ? ({ ...storeNode, ...overrides } as BoxVentNode) : storeNode
|
||||||
|
|
||||||
|
// Pull the parent segment so the Position sliders can clamp to the
|
||||||
|
// segment's footprint (no point letting the user drag the vent off
|
||||||
|
// the eaves into thin air).
|
||||||
|
const segment = useScene((s) =>
|
||||||
|
node?.roofSegmentId
|
||||||
|
? (s.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined)
|
||||||
|
: undefined,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Slider drag (during): write to the ephemeral live-overrides store so
|
||||||
|
// the mesh updates frame-by-frame without thrashing the scene store
|
||||||
|
// (or polluting undo history).
|
||||||
|
const previewProp = useCallback(
|
||||||
|
(updates: Partial<BoxVentNode>) => {
|
||||||
|
if (!selectedId) return
|
||||||
|
useLiveNodeOverrides.getState().set(selectedId as AnyNodeId, updates)
|
||||||
|
},
|
||||||
|
[selectedId],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Slider release (commit): flush to the scene store as a single
|
||||||
|
// undoable change and clear the live override.
|
||||||
|
const commitProp = useCallback(
|
||||||
|
(updates: Partial<BoxVentNode>) => {
|
||||||
|
if (!selectedId) return
|
||||||
|
updateNode(selectedId as AnyNode['id'], updates)
|
||||||
|
useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId)
|
||||||
|
},
|
||||||
|
[selectedId, updateNode],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Discrete / one-shot updates (style switch, end-cap toggle) bypass
|
||||||
|
// the live-override dance — they're never part of a drag.
|
||||||
|
const handleUpdate = commitProp
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setSelection({ selectedIds: [] })
|
||||||
|
}, [setSelection])
|
||||||
|
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
if (node?.roofSegmentId) {
|
||||||
|
setSelection({ selectedIds: [node.roofSegmentId as AnyNode['id']] })
|
||||||
|
}
|
||||||
|
}, [node?.roofSegmentId, setSelection])
|
||||||
|
|
||||||
|
const handleMove = useCallback(() => {
|
||||||
|
if (!node) return
|
||||||
|
triggerSFX('sfx:item-pick')
|
||||||
|
// `setMovingNode`'s type union doesn't include roof-mounted kinds —
|
||||||
|
// skylight / chimney / etc. take the same `as never` escape hatch.
|
||||||
|
setMovingNode(node as never)
|
||||||
|
setSelection({ selectedIds: [] })
|
||||||
|
}, [node, setMovingNode, setSelection])
|
||||||
|
|
||||||
|
const handleDuplicate = useCallback(() => {
|
||||||
|
if (!node) return
|
||||||
|
triggerSFX('sfx:item-pick')
|
||||||
|
const parentId = node.roofSegmentId as AnyNodeId | undefined
|
||||||
|
if (!parentId) return
|
||||||
|
|
||||||
|
// Clone via the schema parser so the new node gets a fresh ID and
|
||||||
|
// valid defaults. Keep position/rotation/dimensions identical to the
|
||||||
|
// source — the user will drag it to its real destination next.
|
||||||
|
const state = useScene.getState()
|
||||||
|
const meta =
|
||||||
|
typeof node.metadata === 'object' && node.metadata !== null
|
||||||
|
? (node.metadata as Record<string, unknown>)
|
||||||
|
: {}
|
||||||
|
const cloneInput = {
|
||||||
|
...node,
|
||||||
|
id: undefined,
|
||||||
|
metadata: { ...meta, isNew: true },
|
||||||
|
} as Record<string, unknown>
|
||||||
|
// Use the schema parser so the new node gets a fresh ID and stays
|
||||||
|
// in sync with the placement-tool defaults.
|
||||||
|
const cloned = BoxVentSchema.parse(cloneInput) as BoxVentNode
|
||||||
|
|
||||||
|
state.createNode(cloned, parentId)
|
||||||
|
state.dirtyNodes.add(parentId)
|
||||||
|
setMovingNode(cloned as never)
|
||||||
|
setSelection({ selectedIds: [] })
|
||||||
|
}, [node, setMovingNode, setSelection])
|
||||||
|
|
||||||
|
const handleDelete = useCallback(() => {
|
||||||
|
if (!(selectedId && node)) return
|
||||||
|
triggerSFX('sfx:item-delete')
|
||||||
|
const segmentId = node.roofSegmentId
|
||||||
|
if (segmentId) {
|
||||||
|
const state = useScene.getState()
|
||||||
|
const segment = state.nodes[segmentId as AnyNodeId] as RoofSegmentNode | undefined
|
||||||
|
if (segment) {
|
||||||
|
state.updateNode(segmentId as AnyNode['id'], {
|
||||||
|
children: (segment.children ?? []).filter((id) => id !== selectedId),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deleteNode(selectedId as AnyNodeId)
|
||||||
|
if (segmentId) {
|
||||||
|
useScene.getState().dirtyNodes.add(segmentId as AnyNodeId)
|
||||||
|
setSelection({ selectedIds: [segmentId as AnyNode['id']] })
|
||||||
|
} else {
|
||||||
|
setSelection({ selectedIds: [] })
|
||||||
|
}
|
||||||
|
}, [selectedId, node, deleteNode, setSelection])
|
||||||
|
|
||||||
|
if (!(node && node.type === 'box-vent' && selectedId)) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PanelWrapper
|
||||||
|
icon="/icons/roof.png"
|
||||||
|
onBack={node.roofSegmentId ? handleBack : undefined}
|
||||||
|
onClose={handleClose}
|
||||||
|
title={node.name || 'Box Vent'}
|
||||||
|
width={300}
|
||||||
|
>
|
||||||
|
<PanelSection title="Style">
|
||||||
|
<SegmentedControl
|
||||||
|
onChange={(v) => handleUpdate({ style: v as BoxVentNode['style'] })}
|
||||||
|
options={[
|
||||||
|
{ label: 'Box', value: 'box' },
|
||||||
|
{ label: 'Cap', value: 'cap' },
|
||||||
|
{ label: 'Dome', value: 'dome' },
|
||||||
|
]}
|
||||||
|
value={node.style ?? 'cap'}
|
||||||
|
/>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection title="Dimensions">
|
||||||
|
<SliderControl
|
||||||
|
label="Width"
|
||||||
|
max={0.8}
|
||||||
|
min={0.15}
|
||||||
|
onChange={(v) => previewProp({ width: v })}
|
||||||
|
onCommit={(v) => handleUpdate({ width: v })}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.01}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round(node.width * 100) / 100}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Depth"
|
||||||
|
max={0.8}
|
||||||
|
min={0.15}
|
||||||
|
onChange={(v) => previewProp({ depth: v })}
|
||||||
|
onCommit={(v) => handleUpdate({ depth: v })}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.01}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round(node.depth * 100) / 100}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Height"
|
||||||
|
max={0.4}
|
||||||
|
min={0.05}
|
||||||
|
onChange={(v) => previewProp({ height: v })}
|
||||||
|
onCommit={(v) => handleUpdate({ height: v })}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.01}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round(node.height * 100) / 100}
|
||||||
|
/>
|
||||||
|
{/* Hood Overhang is `cap`-only — the dome shape rolls down to
|
||||||
|
the body footprint without a flange skirt, and `box` doesn't
|
||||||
|
have a hood at all. Max scales with width so wider vents can
|
||||||
|
flare further past the body. */}
|
||||||
|
{node.style === 'cap' && (
|
||||||
|
<SliderControl
|
||||||
|
label="Hood Overhang"
|
||||||
|
max={Math.max(0.02, node.width)}
|
||||||
|
min={0}
|
||||||
|
onChange={(v) => previewProp({ hoodOverhang: v })}
|
||||||
|
onCommit={(v) => handleUpdate({ hoodOverhang: v })}
|
||||||
|
precision={3}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.005}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round((node.hoodOverhang ?? 0) * 1000) / 1000}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{node.style === 'box' && (
|
||||||
|
<>
|
||||||
|
<SliderControl
|
||||||
|
label="Base Inset"
|
||||||
|
max={Math.max(0.005, Math.min(node.width, node.depth) / 2 - 0.005)}
|
||||||
|
min={0}
|
||||||
|
onChange={(v) => previewProp({ baseInset: v })}
|
||||||
|
onCommit={(v) => handleUpdate({ baseInset: v })}
|
||||||
|
precision={3}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.005}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round((node.baseInset ?? 0.06) * 1000) / 1000}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Base Height"
|
||||||
|
max={Math.max(0.01, node.height - 0.005)}
|
||||||
|
min={0.005}
|
||||||
|
onChange={(v) => previewProp({ baseHeight: v })}
|
||||||
|
onCommit={(v) => handleUpdate({ baseHeight: v })}
|
||||||
|
precision={3}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.005}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round((node.baseHeight ?? 0.04) * 1000) / 1000}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Corner Bevel"
|
||||||
|
max={Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(node.width, node.depth) / 2 - (node.baseInset ?? 0.06) - 0.001,
|
||||||
|
)}
|
||||||
|
min={0}
|
||||||
|
onChange={(v) => previewProp({ cornerBevel: v })}
|
||||||
|
onCommit={(v) => handleUpdate({ cornerBevel: v })}
|
||||||
|
precision={3}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.002}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round((node.cornerBevel ?? 0.012) * 1000) / 1000}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{node.style === 'cap' && (
|
||||||
|
<>
|
||||||
|
<SliderControl
|
||||||
|
label="Cap Height"
|
||||||
|
max={Math.max(0.02, node.height - 0.01)}
|
||||||
|
min={0.01}
|
||||||
|
onChange={(v) => previewProp({ capHeight: v })}
|
||||||
|
onCommit={(v) => handleUpdate({ capHeight: v })}
|
||||||
|
precision={3}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.005}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round((node.capHeight ?? 0.07) * 1000) / 1000}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Gap Height"
|
||||||
|
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 })}
|
||||||
|
precision={3}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.005}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round((node.capGap ?? 0) * 1000) / 1000}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Top Taper"
|
||||||
|
max={1}
|
||||||
|
min={0}
|
||||||
|
onChange={(v) => previewProp({ topTaper: v })}
|
||||||
|
onCommit={(v) => handleUpdate({ topTaper: v })}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.05}
|
||||||
|
unit=""
|
||||||
|
value={Math.round((node.topTaper ?? 0.4) * 100) / 100}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{node.style === 'dome' && (
|
||||||
|
<SliderControl
|
||||||
|
label="Dome Curvature"
|
||||||
|
max={1.2}
|
||||||
|
min={0.3}
|
||||||
|
onChange={(v) => previewProp({ domeCurvature: v })}
|
||||||
|
onCommit={(v) => handleUpdate({ domeCurvature: v })}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.05}
|
||||||
|
unit=""
|
||||||
|
value={Math.round((node.domeCurvature ?? 0.65) * 100) / 100}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection title="Position">
|
||||||
|
<SliderControl
|
||||||
|
label="X"
|
||||||
|
max={Math.round(((segment?.width ?? 10) / 2) * 100) / 100}
|
||||||
|
min={-Math.round(((segment?.width ?? 10) / 2) * 100) / 100}
|
||||||
|
onChange={(v) =>
|
||||||
|
previewProp({
|
||||||
|
position: [v, node.position[1] ?? 0, node.position[2] ?? 0],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onCommit={(v) =>
|
||||||
|
handleUpdate({
|
||||||
|
position: [v, node.position[1] ?? 0, node.position[2] ?? 0],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.05}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round((node.position[0] ?? 0) * 100) / 100}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Y"
|
||||||
|
max={Math.max(
|
||||||
|
(segment?.wallHeight ?? 3) + (segment ? getActiveRoofHeight(segment) : 3) + 2,
|
||||||
|
(node.position[1] ?? 0) + 0.1,
|
||||||
|
)}
|
||||||
|
min={Math.min(0, (node.position[1] ?? 0) - 0.5)}
|
||||||
|
onChange={(v) =>
|
||||||
|
previewProp({
|
||||||
|
position: [node.position[0] ?? 0, v, node.position[2] ?? 0],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onCommit={(v) =>
|
||||||
|
handleUpdate({
|
||||||
|
position: [node.position[0] ?? 0, v, node.position[2] ?? 0],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.05}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round((node.position[1] ?? 0) * 100) / 100}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Z"
|
||||||
|
max={Math.round(((segment?.depth ?? 10) / 2) * 100) / 100}
|
||||||
|
min={-Math.round(((segment?.depth ?? 10) / 2) * 100) / 100}
|
||||||
|
onChange={(v) =>
|
||||||
|
previewProp({
|
||||||
|
position: [node.position[0] ?? 0, node.position[1] ?? 0, v],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onCommit={(v) =>
|
||||||
|
handleUpdate({
|
||||||
|
position: [node.position[0] ?? 0, node.position[1] ?? 0, v],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.05}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round((node.position[2] ?? 0) * 100) / 100}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Rotation"
|
||||||
|
max={180}
|
||||||
|
min={-180}
|
||||||
|
onChange={(deg) => previewProp({ rotation: (deg * Math.PI) / 180 })}
|
||||||
|
onCommit={(deg) => handleUpdate({ rotation: (deg * Math.PI) / 180 })}
|
||||||
|
precision={0}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={1}
|
||||||
|
unit="°"
|
||||||
|
value={Math.round(((node.rotation ?? 0) * 180) / Math.PI)}
|
||||||
|
/>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection title="Actions">
|
||||||
|
<ActionGroup>
|
||||||
|
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||||
|
<ActionButton
|
||||||
|
icon={<Copy className="h-3.5 w-3.5" />}
|
||||||
|
label="Duplicate"
|
||||||
|
onClick={handleDuplicate}
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
className="hover:bg-red-500/20"
|
||||||
|
icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
|
||||||
|
label="Delete"
|
||||||
|
onClick={handleDelete}
|
||||||
|
/>
|
||||||
|
</ActionGroup>
|
||||||
|
</PanelSection>
|
||||||
|
</PanelWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { ParametricDescriptor } from '@pascal-app/core'
|
||||||
|
import type { BoxVentNode } from './schema'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inspector descriptor for the box vent. Position / rotation are
|
||||||
|
* surfaced by the framework via `capabilities.movable` + the bespoke
|
||||||
|
* move tool — not in this descriptor.
|
||||||
|
*/
|
||||||
|
export const boxVentParametrics: ParametricDescriptor<BoxVentNode> = {
|
||||||
|
// Move + Duplicate need the kind-owned ghost-preview flow (see
|
||||||
|
// `./move-tool.tsx`), so the panel hosts those actions itself instead
|
||||||
|
// of relying on the generic inspector — which only knows about Move
|
||||||
|
// for `capabilities.movable` kinds and routes those through the
|
||||||
|
// grid-plane mover.
|
||||||
|
customPanel: () => import('./panel'),
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
label: 'Style',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'style',
|
||||||
|
kind: 'enum',
|
||||||
|
options: ['standard', 'low-profile', 'dome'],
|
||||||
|
display: 'segmented',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Dimensions',
|
||||||
|
fields: [
|
||||||
|
{ key: 'width', kind: 'number', unit: 'm', min: 0.15, max: 0.8, step: 0.01 },
|
||||||
|
{ key: 'depth', kind: 'number', unit: 'm', min: 0.15, max: 0.8, step: 0.01 },
|
||||||
|
{ key: 'height', kind: 'number', unit: 'm', min: 0.05, max: 0.4, step: 0.01 },
|
||||||
|
{ key: 'hoodOverhang', kind: 'number', unit: 'm', min: 0, max: 0.12, step: 0.005 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useMemo } from 'react'
|
||||||
|
import * as THREE from 'three'
|
||||||
|
import { buildBoxVentGeometry } from './geometry'
|
||||||
|
import type { BoxVentNode } from './schema'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translucent ghost of a box vent, used by the placement tool's cursor
|
||||||
|
* and the move-tool preview. Builds the geometry through the shared
|
||||||
|
* pure builder so the ghost shape stays in lockstep with the committed
|
||||||
|
* vent.
|
||||||
|
*
|
||||||
|
* Raycast is disabled on the mesh — the cursor follows the vent, so
|
||||||
|
* leaving raycast active would cause the preview itself to intercept
|
||||||
|
* 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 material = useMemo(
|
||||||
|
() =>
|
||||||
|
new THREE.MeshStandardMaterial({
|
||||||
|
color: 0xff_ff_ff,
|
||||||
|
emissive: 0x6c_a3_ff,
|
||||||
|
emissiveIntensity: 0.18,
|
||||||
|
roughness: 0.85,
|
||||||
|
metalness: 0.05,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.35,
|
||||||
|
depthWrite: false,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry])
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
geometry.dispose()
|
||||||
|
edgesGeometry.dispose()
|
||||||
|
material.dispose()
|
||||||
|
},
|
||||||
|
[geometry, edgesGeometry, material],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group rotation-y={node.rotation ?? 0}>
|
||||||
|
<mesh
|
||||||
|
geometry={geometry}
|
||||||
|
material={material}
|
||||||
|
raycast={() => {
|
||||||
|
/* disabled — see component-level note */
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<lineSegments geometry={edgesGeometry} renderOrder={1000}>
|
||||||
|
<lineBasicMaterial
|
||||||
|
color={0x6c_a3_ff}
|
||||||
|
depthTest={false}
|
||||||
|
opacity={0.95}
|
||||||
|
transparent
|
||||||
|
/>
|
||||||
|
</lineSegments>
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BoxVentPreview
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import {
|
||||||
|
type AnyNodeId,
|
||||||
|
type BoxVentNode,
|
||||||
|
type RoofSegmentNode,
|
||||||
|
useLiveNodeOverrides,
|
||||||
|
useRegistry,
|
||||||
|
useScene,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import { createMaterial, createMaterialFromPresetRef, useNodeEvents } from '@pascal-app/viewer'
|
||||||
|
import { useEffect, useMemo, useRef } from 'react'
|
||||||
|
import * as THREE from 'three'
|
||||||
|
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../solar-panel/geometry'
|
||||||
|
import { buildBoxVentGeometry } from './geometry'
|
||||||
|
|
||||||
|
const defaultMaterial = new THREE.MeshStandardMaterial({
|
||||||
|
color: 0xff_ff_ff,
|
||||||
|
roughness: 0.85,
|
||||||
|
metalness: 0.1,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Box vent renderer. The vent is parented to a roof-segment in the scene
|
||||||
|
* graph, but the registry-era roof-segment renderer doesn't auto-nest
|
||||||
|
* children (it's a single mesh with placeholder geometry filled by
|
||||||
|
* `RoofSystem`). So this renderer reads the parent segment directly and
|
||||||
|
* reproduces the segment-local transform stack manually:
|
||||||
|
*
|
||||||
|
* segment.position → segment.rotation (Y) → vent.position
|
||||||
|
* → slope tilt (X) → vent.rotation (Y) → mesh
|
||||||
|
*
|
||||||
|
* The slope tilt is derived from the segment's roof shape and the vent's
|
||||||
|
* local Z — see `computeBoxVentSlopeTilt`. The +Z side of the segment is
|
||||||
|
* the down-slope direction, so a positive Z lands on the lower half of
|
||||||
|
* the pitch.
|
||||||
|
*
|
||||||
|
* Live segment drags are honoured by subscribing to `useLiveTransforms`
|
||||||
|
* for the parent segment ID — during the segment's move, the override
|
||||||
|
* carries the in-progress position/rotation and the vent follows
|
||||||
|
* smoothly without waiting for a commit.
|
||||||
|
*/
|
||||||
|
const BoxVentRenderer = ({ node: storeNode }: { node: BoxVentNode }) => {
|
||||||
|
const ref = useRef<THREE.Group>(null!)
|
||||||
|
useRegistry(storeNode.id, 'box-vent', ref)
|
||||||
|
const handlers = useNodeEvents(storeNode, 'box-vent')
|
||||||
|
|
||||||
|
// 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 node: BoxVentNode = overrides ? ({ ...storeNode, ...overrides } as BoxVentNode) : storeNode
|
||||||
|
|
||||||
|
const segment = useScene((state) =>
|
||||||
|
node.roofSegmentId
|
||||||
|
? (state.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined)
|
||||||
|
: undefined,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Rebuild geometry whenever any shape-bearing field changes — that's
|
||||||
|
// 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,
|
||||||
|
])
|
||||||
|
|
||||||
|
useEffect(() => () => geometry.dispose(), [geometry])
|
||||||
|
|
||||||
|
// Orient the vent to whatever roof face it sits on. The analytical
|
||||||
|
// normal (shared with solar-panel + skylight) handles every roof type
|
||||||
|
// — gable, shed, hip front, hip side — instead of the previous
|
||||||
|
// X-tilt-from-Z-sign trick, which only worked on slopes whose dip
|
||||||
|
// 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,
|
||||||
|
)
|
||||||
|
return surfaceQuatFromNormal(normal, new THREE.Quaternion())
|
||||||
|
}, [segment, node.position[0], node.position[2]])
|
||||||
|
|
||||||
|
// Paint surface: explicit material wins, then preset, then the cached
|
||||||
|
// default. Mirrors the slab / stair / wall pattern. Preset materials
|
||||||
|
// come from the shared cache with `side: FrontSide`; clone + force
|
||||||
|
// 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(() => {
|
||||||
|
const base = node.material
|
||||||
|
? createMaterial(node.material)
|
||||||
|
: (createMaterialFromPresetRef(node.materialPreset) ?? defaultMaterial)
|
||||||
|
if (base.side === THREE.DoubleSide) return base
|
||||||
|
const cloned = base.clone()
|
||||||
|
cloned.side = THREE.DoubleSide
|
||||||
|
return cloned
|
||||||
|
}, [node.material, node.materialPreset])
|
||||||
|
|
||||||
|
if (!segment) return null
|
||||||
|
|
||||||
|
// `node.position` is segment-local (the placement + move tools resolve
|
||||||
|
// the click via `segObj.worldToLocal`). The vent is mounted in the
|
||||||
|
// roof's `roof-elements` group, which carries only the roof transform
|
||||||
|
// — so we replicate the segment's roof-local transform here to bridge
|
||||||
|
// the two frames. Without this, segment-local coords would be rendered
|
||||||
|
// *as if* they were roof-local; on gable / hip roofs (where every
|
||||||
|
// segment shares the roof origin but differs by Y rotation), the vent
|
||||||
|
// would land rotated away from the click — the "slight shift" between
|
||||||
|
// ghost and committed mesh.
|
||||||
|
const segPos = segment.position ?? [0, 0, 0]
|
||||||
|
const segRotY = segment.rotation ?? 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group position={segPos} rotation-y={segRotY}>
|
||||||
|
<group
|
||||||
|
position={[node.position[0] ?? 0, node.position[1] ?? 0, node.position[2] ?? 0]}
|
||||||
|
ref={ref}
|
||||||
|
visible={node.visible}
|
||||||
|
>
|
||||||
|
<group quaternion={surfaceQuat}>
|
||||||
|
<group rotation-y={node.rotation ?? 0}>
|
||||||
|
<mesh
|
||||||
|
castShadow
|
||||||
|
geometry={geometry}
|
||||||
|
material={material}
|
||||||
|
name="box-vent-surface"
|
||||||
|
receiveShadow
|
||||||
|
{...handlers}
|
||||||
|
/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BoxVentRenderer
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// Schema lives in core (referenced by the AnyNode union). Re-export so
|
||||||
|
// every box-vent-related import stays inside @pascal-app/nodes/box-vent.
|
||||||
|
export { BoxVentNode } from '@pascal-app/core'
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import {
|
||||||
|
type AnyNodeId,
|
||||||
|
BoxVentNode,
|
||||||
|
emitter,
|
||||||
|
type RoofEvent,
|
||||||
|
type RoofNode,
|
||||||
|
sceneRegistry,
|
||||||
|
useScene,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
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 { boxVentDefinition } from './definition'
|
||||||
|
import BoxVentPreview from './preview'
|
||||||
|
|
||||||
|
const worldPoint = new THREE.Vector3()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Box-vent placement tool. Mounts when the palette activates the
|
||||||
|
* box-vent kind; listens for `roof:*` events; on click commits a new
|
||||||
|
* `BoxVentNode` parented to the targeted segment with segment-local
|
||||||
|
* coordinates.
|
||||||
|
*
|
||||||
|
* Cursor preview follows the roof surface: position from `roof:move`,
|
||||||
|
* slope tilt from the segment under the cursor, segment yaw from the
|
||||||
|
* roof + segment rotation stack.
|
||||||
|
*/
|
||||||
|
const BoxVentTool = () => {
|
||||||
|
const activeBuildingId = useViewer((s) => s.selection.buildingId)
|
||||||
|
const setSelection = useViewer((s) => s.setSelection)
|
||||||
|
|
||||||
|
const [previewPos, setPreviewPos] = useState<[number, number, number] | null>(null)
|
||||||
|
const [previewSurfaceQuat, setPreviewSurfaceQuat] = useState<THREE.Quaternion | null>(null)
|
||||||
|
const [previewYaw, setPreviewYaw] = useState(0)
|
||||||
|
const lastSnapRef = useRef<[number, number] | null>(null)
|
||||||
|
|
||||||
|
// Default-shaped preview node — matches what the commit will create.
|
||||||
|
const previewNode = useMemo(
|
||||||
|
() =>
|
||||||
|
BoxVentNode.parse({
|
||||||
|
...boxVentDefinition.defaults(),
|
||||||
|
name: 'Box Vent',
|
||||||
|
position: [0, 0, 0],
|
||||||
|
rotation: 0,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeBuildingId) return
|
||||||
|
|
||||||
|
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)
|
||||||
|
buildingObj.worldToLocal(worldPoint)
|
||||||
|
return [worldPoint.x, worldPoint.y, worldPoint.z]
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePreview = (event: RoofEvent) => {
|
||||||
|
const wx = event.position[0]
|
||||||
|
const wy = event.position[1]
|
||||||
|
const wz = event.position[2]
|
||||||
|
|
||||||
|
const sx = Math.round(wx * 20) / 20
|
||||||
|
const sz = Math.round(wz * 20) / 20
|
||||||
|
const prev = lastSnapRef.current
|
||||||
|
if (!prev || prev[0] !== sx || prev[1] !== sz) {
|
||||||
|
triggerSFX('sfx:grid-snap')
|
||||||
|
lastSnapRef.current = [sx, sz]
|
||||||
|
}
|
||||||
|
|
||||||
|
const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz)
|
||||||
|
if (!hit) return
|
||||||
|
|
||||||
|
const normal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment)
|
||||||
|
setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion()))
|
||||||
|
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0))
|
||||||
|
setPreviewPos(worldToBuildingLocal(wx, wy, wz))
|
||||||
|
event.stopPropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onClick = (event: RoofEvent) => {
|
||||||
|
const hit = resolveRoofSegmentHit(
|
||||||
|
event.node as RoofNode,
|
||||||
|
event.position[0],
|
||||||
|
event.position[1],
|
||||||
|
event.position[2],
|
||||||
|
)
|
||||||
|
if (!hit) return
|
||||||
|
const state = useScene.getState()
|
||||||
|
|
||||||
|
const vent = BoxVentNode.parse({
|
||||||
|
...boxVentDefinition.defaults(),
|
||||||
|
name: 'Box Vent',
|
||||||
|
roofSegmentId: hit.segment.id,
|
||||||
|
position: [hit.localX, hit.localY, hit.localZ],
|
||||||
|
rotation: 0,
|
||||||
|
})
|
||||||
|
state.createNode(vent, hit.segment.id as AnyNodeId)
|
||||||
|
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
||||||
|
setSelection({ selectedIds: [vent.id] })
|
||||||
|
triggerSFX('sfx:item-place')
|
||||||
|
event.stopPropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
emitter.on('roof:move', updatePreview)
|
||||||
|
emitter.on('roof:enter', updatePreview)
|
||||||
|
emitter.on('roof:click', onClick)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
emitter.off('roof:move', updatePreview)
|
||||||
|
emitter.off('roof:enter', updatePreview)
|
||||||
|
emitter.off('roof:click', onClick)
|
||||||
|
}
|
||||||
|
}, [activeBuildingId, setSelection])
|
||||||
|
|
||||||
|
if (!activeBuildingId || !previewPos || !previewSurfaceQuat) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group position={previewPos}>
|
||||||
|
<group rotation-y={previewYaw}>
|
||||||
|
<group quaternion={previewSurfaceQuat}>
|
||||||
|
<BoxVentPreview node={previewNode} />
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BoxVentTool
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import type { RoofSegmentNode } from '@pascal-app/core'
|
||||||
|
import { buildChimneyGeometry, flueXPositions } from '../geometry'
|
||||||
|
import { ChimneyNode } from '../schema'
|
||||||
|
|
||||||
|
const fixtureSegment = (): RoofSegmentNode =>
|
||||||
|
({
|
||||||
|
object: 'node',
|
||||||
|
id: 'rseg_fixture',
|
||||||
|
type: 'roof-segment',
|
||||||
|
parentId: null,
|
||||||
|
visible: true,
|
||||||
|
metadata: {},
|
||||||
|
position: [0, 0, 0],
|
||||||
|
rotation: 0,
|
||||||
|
roofType: 'gable',
|
||||||
|
width: 8,
|
||||||
|
depth: 6,
|
||||||
|
wallHeight: 2.5,
|
||||||
|
// atan(2 / 3)° — gives getActiveRoofHeight ≈ 2.0 on this 8×6 gable.
|
||||||
|
pitch: (Math.atan2(2, 3) * 180) / Math.PI,
|
||||||
|
wallThickness: 0.1,
|
||||||
|
deckThickness: 0.1,
|
||||||
|
overhang: 0.3,
|
||||||
|
shingleThickness: 0.05,
|
||||||
|
}) as RoofSegmentNode
|
||||||
|
|
||||||
|
describe('buildChimneyGeometry', () => {
|
||||||
|
test('returns body for default chimney with a non-empty position attribute', () => {
|
||||||
|
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)
|
||||||
|
expect(cricket).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('cap omitted when capShape=none', () => {
|
||||||
|
const { cap } = buildChimneyGeometry(
|
||||||
|
ChimneyNode.parse({ cap: true, capShape: 'none' }),
|
||||||
|
fixtureSegment(),
|
||||||
|
)
|
||||||
|
expect(cap).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('cap omitted when cap=false', () => {
|
||||||
|
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(),
|
||||||
|
)
|
||||||
|
expect(flues).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('cricket only emitted for square body with non-none style', () => {
|
||||||
|
const square = buildChimneyGeometry(
|
||||||
|
ChimneyNode.parse({ cricketStyle: 'simple', bodyShape: 'square' }),
|
||||||
|
fixtureSegment(),
|
||||||
|
)
|
||||||
|
expect(square.cricket?.getAttribute('position').count).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
const round = buildChimneyGeometry(
|
||||||
|
ChimneyNode.parse({ cricketStyle: 'simple', bodyShape: 'round' }),
|
||||||
|
fixtureSegment(),
|
||||||
|
)
|
||||||
|
expect(round.cricket).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shoulder style materially increases body vertex count for tapered/corbeled', () => {
|
||||||
|
const none = buildChimneyGeometry(
|
||||||
|
ChimneyNode.parse({ shoulderStyle: 'none' }),
|
||||||
|
fixtureSegment(),
|
||||||
|
).body.getAttribute('position').count
|
||||||
|
const tapered = buildChimneyGeometry(
|
||||||
|
ChimneyNode.parse({ shoulderStyle: 'tapered' }),
|
||||||
|
fixtureSegment(),
|
||||||
|
).body.getAttribute('position').count
|
||||||
|
const corbeled = buildChimneyGeometry(
|
||||||
|
ChimneyNode.parse({ shoulderStyle: 'corbeled' }),
|
||||||
|
fixtureSegment(),
|
||||||
|
).body.getAttribute('position').count
|
||||||
|
expect(tapered).toBeGreaterThan(none)
|
||||||
|
expect(corbeled).toBeGreaterThan(tapered)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('flueXPositions', () => {
|
||||||
|
test('count=0 returns []', () => {
|
||||||
|
expect(flueXPositions(0, 0.6, 0.22)).toEqual([])
|
||||||
|
})
|
||||||
|
test('count=1 returns [0]', () => {
|
||||||
|
expect(flueXPositions(1, 0.6, 0.22)).toEqual([0])
|
||||||
|
})
|
||||||
|
test('count=4 spans the available width at spacing=1', () => {
|
||||||
|
const xs = flueXPositions(4, 0.6, 0.1, 1)
|
||||||
|
expect(xs.length).toBe(4)
|
||||||
|
expect(xs[0]).toBeCloseTo(-(0.6 - 0.1) / 2)
|
||||||
|
expect(xs[3]).toBeCloseTo((0.6 - 0.1) / 2)
|
||||||
|
})
|
||||||
|
test('spacing=0 collapses all to center', () => {
|
||||||
|
const xs = flueXPositions(3, 0.6, 0.1, 0)
|
||||||
|
expect(xs.every((x) => Math.abs(x) < 1e-6)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
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
|
||||||
|
// panel does when it calls `commitProp(chimneyPresets[key])`.
|
||||||
|
const applyPreset = (key: keyof typeof chimneyPresets) =>
|
||||||
|
({ ...ChimneyNode.parse({}), ...chimneyPresets[key] }) as Parameters<
|
||||||
|
typeof detectActiveChimneyPreset
|
||||||
|
>[0]
|
||||||
|
|
||||||
|
describe('detectActiveChimneyPreset', () => {
|
||||||
|
test('returns null when no node is supplied', () => {
|
||||||
|
expect(detectActiveChimneyPreset(null)).toBeNull()
|
||||||
|
expect(detectActiveChimneyPreset(undefined)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns null for a freshly-parsed default chimney (no preset applied)', () => {
|
||||||
|
// The schema's defaults are deliberately neutral — they should NOT
|
||||||
|
// accidentally match one of the curated presets. If they do, the
|
||||||
|
// panel will show a preset as active on every fresh chimney and the
|
||||||
|
// user has no "custom starting state".
|
||||||
|
expect(detectActiveChimneyPreset(ChimneyNode.parse({}))).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test.each(CHIMNEY_PRESET_KEYS)('round-trips %s preset', (key) => {
|
||||||
|
expect(detectActiveChimneyPreset(applyPreset(key))).toBe(key)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns null after the user tweaks a field away from the preset', () => {
|
||||||
|
const node = applyPreset('brick')
|
||||||
|
// Brick preset sets bandStyle=double; flip it to confirm the
|
||||||
|
// detection narrows.
|
||||||
|
expect(
|
||||||
|
detectActiveChimneyPreset({ ...node, bandStyle: 'single' as const }),
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ignores non-preset fields (dimensions, materials, placement)', () => {
|
||||||
|
// The whole point of the preset model: applying brick to an
|
||||||
|
// already-sized chimney doesn't reset its width/depth/material, and
|
||||||
|
// varying those alone shouldn't kick it out of "Brick".
|
||||||
|
const node = applyPreset('brick')
|
||||||
|
expect(
|
||||||
|
detectActiveChimneyPreset({
|
||||||
|
...node,
|
||||||
|
width: 1.2,
|
||||||
|
depth: 0.8,
|
||||||
|
heightAboveRidge: 2.5,
|
||||||
|
position: [3, 0, -1] as [number, number, number],
|
||||||
|
rotation: 0.7,
|
||||||
|
materialPreset: 'preset-brick-redbrown',
|
||||||
|
topMaterialPreset: 'preset-concrete',
|
||||||
|
}),
|
||||||
|
).toBe('brick')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import { ChimneyNode } from '../schema'
|
||||||
|
|
||||||
|
describe('ChimneyNode schema', () => {
|
||||||
|
test('parses with defaults', () => {
|
||||||
|
const parsed = ChimneyNode.parse({})
|
||||||
|
expect(parsed.type).toBe('chimney')
|
||||||
|
expect(parsed.id).toMatch(/^chimney_/)
|
||||||
|
expect(parsed.width).toBe(0.6)
|
||||||
|
expect(parsed.depth).toBe(0.6)
|
||||||
|
expect(parsed.heightAboveRidge).toBe(1.0)
|
||||||
|
expect(parsed.bodyShape).toBe('square')
|
||||||
|
expect(parsed.cap).toBe(true)
|
||||||
|
expect(parsed.capShape).toBe('sloped')
|
||||||
|
expect(parsed.flueCount).toBe(1)
|
||||||
|
expect(parsed.shoulderStyle).toBe('none')
|
||||||
|
expect(parsed.cricketStyle).toBe('none')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('accepts every body shape and cap shape', () => {
|
||||||
|
for (const bodyShape of ['square', 'round'] as const) {
|
||||||
|
expect(ChimneyNode.parse({ bodyShape }).bodyShape).toBe(bodyShape)
|
||||||
|
}
|
||||||
|
for (const capShape of ['none', 'sloped', 'flat', 'stepped'] as const) {
|
||||||
|
expect(ChimneyNode.parse({ capShape }).capShape).toBe(capShape)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects flueCount out of [0,4]', () => {
|
||||||
|
expect(() => ChimneyNode.parse({ flueCount: -1 })).toThrow()
|
||||||
|
expect(() => ChimneyNode.parse({ flueCount: 5 })).toThrow()
|
||||||
|
expect(() => ChimneyNode.parse({ flueCount: 1.5 })).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects unknown enums', () => {
|
||||||
|
expect(() => ChimneyNode.parse({ shoulderStyle: 'bogus' })).toThrow()
|
||||||
|
expect(() => ChimneyNode.parse({ cricketSide: 'side' })).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { type NodeDefinition, ChimneyNode as ChimneyNodeSchema } from '@pascal-app/core'
|
||||||
|
import { chimneyPaint } from './paint'
|
||||||
|
import { chimneyParametrics } from './parametrics'
|
||||||
|
import { ChimneyNode } from './schema'
|
||||||
|
|
||||||
|
// Every fresh chimney starts as plain white (body + top). The paint
|
||||||
|
// flow / material picker writes preset refs or full `MaterialSchema`
|
||||||
|
// objects on top of this; until then both roles render `#ffffff`.
|
||||||
|
const WHITE_MATERIAL = {
|
||||||
|
properties: {
|
||||||
|
color: '#ffffff',
|
||||||
|
roughness: 0.85,
|
||||||
|
metalness: 0,
|
||||||
|
opacity: 1,
|
||||||
|
transparent: false,
|
||||||
|
side: 'front' as const,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chimney — a vertical masonry stack hosted on a roof segment.
|
||||||
|
*
|
||||||
|
* Three-checkbox model: `def.renderer` (custom — segment-aware
|
||||||
|
* geometry from `useScene`, body height derived from
|
||||||
|
* `segment.wallHeight + roofHeight + heightAboveRidge`), no `geometry`,
|
||||||
|
* no `system`.
|
||||||
|
*
|
||||||
|
* **Option C scope**: chimney ships in the registry shape with solid
|
||||||
|
* geometry. CSG-driven decoration (cap flue holes, body cavity,
|
||||||
|
* panels, bands) is preserved in the schema but not rendered yet —
|
||||||
|
* those re-light when roof-segment migrates to Stage B and introduces
|
||||||
|
* a `roofCutout` capability the parent segment can read.
|
||||||
|
*/
|
||||||
|
export const chimneyDefinition: NodeDefinition<typeof ChimneyNode> = {
|
||||||
|
kind: 'chimney',
|
||||||
|
schemaVersion: 1,
|
||||||
|
schema: ChimneyNode,
|
||||||
|
category: 'structure',
|
||||||
|
|
||||||
|
defaults: () => {
|
||||||
|
const stub = ChimneyNodeSchema.parse({
|
||||||
|
id: 'chimney_default' as never,
|
||||||
|
type: 'chimney',
|
||||||
|
material: WHITE_MATERIAL,
|
||||||
|
topMaterial: WHITE_MATERIAL,
|
||||||
|
})
|
||||||
|
const { id: _id, type: _type, ...rest } = stub
|
||||||
|
return rest
|
||||||
|
},
|
||||||
|
|
||||||
|
capabilities: {
|
||||||
|
selectable: { hitVolume: 'bbox' },
|
||||||
|
duplicable: true,
|
||||||
|
deletable: true,
|
||||||
|
// Mounts on a roof segment via `roofSegmentId`. Dirty marks
|
||||||
|
// cascade to the host segment's parent roof so its merged shell
|
||||||
|
// re-renders. No `buildCut` — the chimney does its own self-trim
|
||||||
|
// via `trimChimneyBodyAgainstRoof`; the host roof shell stays solid
|
||||||
|
// underneath.
|
||||||
|
roofAccessory: {},
|
||||||
|
// Paint dispatch for the body / top surface split. The editor's
|
||||||
|
// selection-manager routes paint hover / click / preview through
|
||||||
|
// this entry rather than carrying a kind-name arm.
|
||||||
|
paint: chimneyPaint,
|
||||||
|
},
|
||||||
|
|
||||||
|
affordanceTools: {
|
||||||
|
// Drag-to-place tool for duplicate + move. Reuses the placement
|
||||||
|
// ghost preview but seeds it from the moving (cloned) node so the
|
||||||
|
// duplicate keeps the source's body shape, materials, panels, etc.
|
||||||
|
move: () => import('./move-tool'),
|
||||||
|
},
|
||||||
|
|
||||||
|
parametrics: chimneyParametrics,
|
||||||
|
|
||||||
|
renderer: {
|
||||||
|
kind: 'parametric',
|
||||||
|
module: () => import('./renderer'),
|
||||||
|
},
|
||||||
|
|
||||||
|
tool: () => import('./tool'),
|
||||||
|
toolHints: [
|
||||||
|
{ key: 'Left click', label: 'Place chimney on roof' },
|
||||||
|
{ key: 'Esc', label: 'Cancel' },
|
||||||
|
],
|
||||||
|
|
||||||
|
presentation: {
|
||||||
|
label: 'Chimney',
|
||||||
|
description: 'Vertical masonry stack on a roof segment.',
|
||||||
|
icon: { kind: 'url', src: '/icons/roof.png' },
|
||||||
|
paletteSection: 'structure',
|
||||||
|
paletteOrder: 122,
|
||||||
|
},
|
||||||
|
|
||||||
|
mcp: {
|
||||||
|
description:
|
||||||
|
'A chimney on a roof segment. Square or round body; optional shoulder taper; sloped/flat/stepped cap; up to 4 protruding flues; optional cricket on the up-slope face.',
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,629 @@
|
|||||||
|
import { type ChimneyNode, getActiveRoofHeight, type RoofSegmentNode } from '@pascal-app/core'
|
||||||
|
import * as THREE from 'three'
|
||||||
|
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure chimney geometry builder. Returns body, cap, flues, and cricket
|
||||||
|
* as separate BufferGeometries so each can carry its own material
|
||||||
|
* (body/top split mirrors the schema's `material` vs `topMaterial`).
|
||||||
|
*
|
||||||
|
* **Option C scope** (see commit message): no CSG. The chimney body
|
||||||
|
* intersects the roof at the deck line; the cap is solid (no flue
|
||||||
|
* holes carved); the body has no hollow shaft cavity; flues are solid
|
||||||
|
* cylinders/boxes protruding from the cap. Decorative bands and inset
|
||||||
|
* panels are no-op on this builder until roof-segment migrates to
|
||||||
|
* Stage B and a `roofCutout` capability lets the parent segment own
|
||||||
|
* its own boolean operations.
|
||||||
|
*
|
||||||
|
* Pure: no React, no scene access, no store mutation. Takes the
|
||||||
|
* segment as a second argument so the body height can be derived from
|
||||||
|
* the segment's pitch — analogous to `door`'s `ctx.parent` access.
|
||||||
|
*/
|
||||||
|
export type ChimneyGeometry = {
|
||||||
|
body: THREE.BufferGeometry
|
||||||
|
cap: THREE.BufferGeometry | null
|
||||||
|
flues: THREE.BufferGeometry | null
|
||||||
|
cricket: THREE.BufferGeometry | null
|
||||||
|
bands: THREE.BufferGeometry | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small air gap between the body top and the cap bottom — without it
|
||||||
|
// the cap reads as glued onto the body; this slot catches a shadow
|
||||||
|
// line and sells the cap as a separate stone/metal piece.
|
||||||
|
const CAP_REVEAL = 0.003
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smooth-shaded indexed cylinder. Used for every round body / cap /
|
||||||
|
* band section. `THREE.CylinderGeometry` gives us:
|
||||||
|
* - shared side vertices across adjacent radial segments → smooth
|
||||||
|
* cylindrical shading (the previous unindexed pusher made every
|
||||||
|
* 24-segment chimney visibly faceted),
|
||||||
|
* - separate cap-rim vertices → crisp top/bottom edges,
|
||||||
|
* - radial UV projection on the caps (vs. the previous (0,0) smear).
|
||||||
|
*/
|
||||||
|
function buildSmoothCylinder(
|
||||||
|
yBot: number,
|
||||||
|
yTop: number,
|
||||||
|
rBot: number,
|
||||||
|
rTop: number,
|
||||||
|
segments = 24,
|
||||||
|
): THREE.BufferGeometry {
|
||||||
|
const h = Math.max(1e-4, yTop - yBot)
|
||||||
|
const cy = (yTop + yBot) / 2
|
||||||
|
// CylinderGeometry params: radiusTop, radiusBottom, height, radialSegments,
|
||||||
|
// heightSegments, openEnded.
|
||||||
|
const geo = new THREE.CylinderGeometry(rTop, rBot, h, segments, 1, false)
|
||||||
|
geo.translate(0, cy, 0)
|
||||||
|
return geo
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeAndDispose(parts: THREE.BufferGeometry[]): THREE.BufferGeometry {
|
||||||
|
if (parts.length === 1) return parts[0]!
|
||||||
|
const merged = mergeGeometries(parts, false)
|
||||||
|
if (!merged) return parts[0]!
|
||||||
|
for (const p of parts) p.dispose()
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
// above the roof when the chimney sits over a low-slope segment.
|
||||||
|
const baseY = Math.max(0, segment.wallHeight - 0.2)
|
||||||
|
|
||||||
|
const body = buildBodyGeometry(node, baseY, topY)
|
||||||
|
|
||||||
|
let cap: THREE.BufferGeometry | null = null
|
||||||
|
let capTopY = topY
|
||||||
|
if (node.cap && node.capShape !== 'none') {
|
||||||
|
// Inset the cap by `CAP_REVEAL` above the body top so a shadow
|
||||||
|
// line separates them.
|
||||||
|
const capBaseY = topY + CAP_REVEAL
|
||||||
|
cap = buildCapGeometry(node, capBaseY)
|
||||||
|
capTopY = capBaseY + node.capThickness
|
||||||
|
}
|
||||||
|
|
||||||
|
let flues: THREE.BufferGeometry | null = null
|
||||||
|
if (node.flueCount > 0) {
|
||||||
|
flues = buildFluesGeometry(node, capTopY)
|
||||||
|
}
|
||||||
|
|
||||||
|
let cricket: THREE.BufferGeometry | null = null
|
||||||
|
if (node.cricketStyle !== 'none' && node.bodyShape !== 'round') {
|
||||||
|
cricket = buildCricketGeometry(node, baseY)
|
||||||
|
}
|
||||||
|
|
||||||
|
let bands: THREE.BufferGeometry | null = null
|
||||||
|
if (node.bandStyle !== 'none') {
|
||||||
|
bands = buildBandsGeometry(node, baseY, topY)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { body, cap, flues, cricket, bands }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Body ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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
|
||||||
|
const r = w / 2
|
||||||
|
|
||||||
|
const style = node.shoulderStyle
|
||||||
|
const ext = Math.max(0, node.shoulderExtent)
|
||||||
|
const sh = Math.max(0.05, Math.min(node.shoulderHeight, topY - baseY - 0.05))
|
||||||
|
|
||||||
|
if (isRound) {
|
||||||
|
// Round body — assemble from smooth-shaded indexed cylinder pieces.
|
||||||
|
// Each shoulder tier is its own cylinder so corbeled steps stay
|
||||||
|
// crisp; the merge below preserves indices.
|
||||||
|
const parts: THREE.BufferGeometry[] = []
|
||||||
|
if (style === 'none') {
|
||||||
|
parts.push(buildSmoothCylinder(baseY, topY, r, r))
|
||||||
|
} else if (style === 'tapered') {
|
||||||
|
parts.push(buildSmoothCylinder(baseY, baseY + sh, r + ext, r))
|
||||||
|
parts.push(buildSmoothCylinder(baseY + sh, topY, r, r))
|
||||||
|
} else {
|
||||||
|
// corbeled — three stepped tiers, then the straight shaft above.
|
||||||
|
const tiers = 3
|
||||||
|
const tierH = sh / tiers
|
||||||
|
for (let i = 0; i < tiers; i++) {
|
||||||
|
const f = i / tiers
|
||||||
|
const yBot = baseY + i * tierH
|
||||||
|
const yTop = baseY + (i + 1) * tierH
|
||||||
|
const rr = r + ext * (1 - f)
|
||||||
|
parts.push(buildSmoothCylinder(yBot, yTop, rr, rr))
|
||||||
|
}
|
||||||
|
parts.push(buildSmoothCylinder(baseY + sh, topY, r, r))
|
||||||
|
}
|
||||||
|
const merged = mergeAndDispose(parts)
|
||||||
|
applyNodeTransform(merged, node)
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
// Square body — keep the unindexed face emitter; pass cornerBevel
|
||||||
|
// so each slab section's vertical corners are chamfered into 45°
|
||||||
|
// faces. The chamfer catches a highlight on every edge and reads as
|
||||||
|
// a masonry chimney instead of a plastic box.
|
||||||
|
const positions: number[] = []
|
||||||
|
const uvs: number[] = []
|
||||||
|
const bevel = Math.max(0, node.cornerBevel ?? 0)
|
||||||
|
|
||||||
|
if (style === 'none') {
|
||||||
|
pushSlabFaces(positions, uvs, baseY, topY, w / 2, d / 2, w / 2, d / 2, bevel)
|
||||||
|
} else if (style === 'tapered') {
|
||||||
|
pushSlabFaces(positions, uvs, baseY, baseY + sh, w / 2 + ext, d / 2 + ext, w / 2, d / 2, bevel)
|
||||||
|
pushSlabFaces(positions, uvs, baseY + sh, topY, w / 2, d / 2, w / 2, d / 2, bevel)
|
||||||
|
} else {
|
||||||
|
const tiers = 3
|
||||||
|
const tierH = sh / tiers
|
||||||
|
for (let i = 0; i < tiers; i++) {
|
||||||
|
const f = i / tiers
|
||||||
|
const yBot = baseY + i * tierH
|
||||||
|
const yTop = baseY + (i + 1) * tierH
|
||||||
|
const hw = w / 2 + ext * (1 - f)
|
||||||
|
const hd = d / 2 + ext * (1 - f)
|
||||||
|
pushSlabFaces(positions, uvs, yBot, yTop, hw, hd, hw, hd, bevel)
|
||||||
|
}
|
||||||
|
pushSlabFaces(positions, uvs, baseY + sh, topY, w / 2, d / 2, w / 2, d / 2, bevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
const geo = buildBufferGeometry(positions, uvs)
|
||||||
|
applyNodeTransform(geo, node)
|
||||||
|
geo.computeVertexNormals()
|
||||||
|
return geo
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Cap ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function buildCapGeometry(node: ChimneyNode, capBaseY: number): THREE.BufferGeometry {
|
||||||
|
const overhang = Math.max(0, node.capOverhang)
|
||||||
|
const t = node.capThickness
|
||||||
|
const isRound = node.bodyShape === 'round'
|
||||||
|
const halfW = node.width / 2 + overhang
|
||||||
|
const halfD = (isRound ? node.width : node.depth) / 2 + overhang
|
||||||
|
const halfWInner = node.width / 2
|
||||||
|
const halfDInner = (isRound ? node.width : node.depth) / 2
|
||||||
|
|
||||||
|
const y0 = capBaseY
|
||||||
|
const y1 = capBaseY + t
|
||||||
|
|
||||||
|
if (isRound) {
|
||||||
|
const parts: THREE.BufferGeometry[] = []
|
||||||
|
switch (node.capShape) {
|
||||||
|
case 'flat':
|
||||||
|
parts.push(buildSmoothCylinder(y0, y1, halfW, halfW))
|
||||||
|
break
|
||||||
|
case 'stepped': {
|
||||||
|
const tiers = 3
|
||||||
|
const tT = t / tiers
|
||||||
|
for (let i = 0; i < tiers; i++) {
|
||||||
|
const f = i / tiers
|
||||||
|
const yBot = y0 + i * tT
|
||||||
|
const yTop = y0 + (i + 1) * tT
|
||||||
|
const rr = halfW + (halfWInner - halfW) * f
|
||||||
|
parts.push(buildSmoothCylinder(yBot, yTop, rr, rr))
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// 'sloped' — taper from overhang base to chimney footprint at top
|
||||||
|
parts.push(buildSmoothCylinder(y0, y1, halfW, halfWInner))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
const merged = mergeAndDispose(parts)
|
||||||
|
applyNodeTransform(merged, node)
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
// Square cap — unindexed slabs, optional corner chamfer.
|
||||||
|
const positions: number[] = []
|
||||||
|
const uvs: number[] = []
|
||||||
|
const bevel = Math.max(0, node.cornerBevel ?? 0)
|
||||||
|
switch (node.capShape) {
|
||||||
|
case 'flat':
|
||||||
|
pushSlabFaces(positions, uvs, y0, y1, halfW, halfD, halfW, halfD, bevel)
|
||||||
|
break
|
||||||
|
case 'stepped': {
|
||||||
|
const tiers = 3
|
||||||
|
const tT = t / tiers
|
||||||
|
for (let i = 0; i < tiers; i++) {
|
||||||
|
const f = i / tiers
|
||||||
|
const yBot = y0 + i * tT
|
||||||
|
const yTop = y0 + (i + 1) * tT
|
||||||
|
const hw = halfW + (halfWInner - halfW) * f
|
||||||
|
const hd = halfD + (halfDInner - halfD) * f
|
||||||
|
pushSlabFaces(positions, uvs, yBot, yTop, hw, hd, hw, hd, bevel)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
pushSlabFaces(positions, uvs, y0, y1, halfW, halfD, halfWInner, halfDInner, bevel)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
const geo = buildBufferGeometry(positions, uvs)
|
||||||
|
applyNodeTransform(geo, node)
|
||||||
|
geo.computeVertexNormals()
|
||||||
|
return geo
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Flues ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function flueXPositions(
|
||||||
|
count: number,
|
||||||
|
chimneyWidth: number,
|
||||||
|
flueDiameter: number,
|
||||||
|
spacing = 1,
|
||||||
|
): number[] {
|
||||||
|
if (count <= 0) return []
|
||||||
|
if (count === 1) return [0]
|
||||||
|
const fullAvailable = Math.max(0, chimneyWidth - flueDiameter)
|
||||||
|
const available = fullAvailable * Math.max(0, Math.min(1, spacing))
|
||||||
|
const xs: number[] = []
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
xs.push(-available / 2 + (i * available) / (count - 1))
|
||||||
|
}
|
||||||
|
return xs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flue-pot proportions. The previous renderer drew each flue as a
|
||||||
|
// single straight cylinder/box — visually a "drainpipe", not a chimney
|
||||||
|
// pot. Real terracotta pots have a tall shaft topped by a short
|
||||||
|
// overhanging rim; this two-tier silhouette is the cheapest geometry
|
||||||
|
// that reads as a pot. Total height still equals `flueHeight`, so the
|
||||||
|
// bore cutter in `holes.ts` covers the whole envelope unchanged.
|
||||||
|
const FLUE_RIM_HEIGHT_RATIO = 0.12 // 12 % of total height, capped below
|
||||||
|
const FLUE_RIM_HEIGHT_MAX = 0.04 // 4 cm — bigger than this looks chunky
|
||||||
|
const FLUE_RIM_OVERHANG_RATIO = 0.12 // 12 % of flue diameter, radially
|
||||||
|
|
||||||
|
function buildFluesGeometry(node: ChimneyNode, capTopY: number): THREE.BufferGeometry | null {
|
||||||
|
const count = Math.max(0, Math.min(4, node.flueCount))
|
||||||
|
if (count === 0) return null
|
||||||
|
|
||||||
|
const d = Math.max(0.02, node.flueDiameter)
|
||||||
|
const h = Math.max(0.02, node.flueHeight)
|
||||||
|
const xs = flueXPositions(count, node.width, d, node.flueSpacing)
|
||||||
|
const parts: THREE.BufferGeometry[] = []
|
||||||
|
|
||||||
|
const rimHeight = Math.min(h * FLUE_RIM_HEIGHT_RATIO, FLUE_RIM_HEIGHT_MAX)
|
||||||
|
const shaftHeight = h - rimHeight
|
||||||
|
const rimOverhang = d * FLUE_RIM_OVERHANG_RATIO
|
||||||
|
|
||||||
|
for (const x of xs) {
|
||||||
|
const yBot = capTopY
|
||||||
|
const yShaftTop = capTopY + shaftHeight
|
||||||
|
|
||||||
|
if (node.flueShape === 'square') {
|
||||||
|
const shaft = new THREE.BoxGeometry(d, shaftHeight, d)
|
||||||
|
shaft.translate(x, yBot + shaftHeight / 2, 0)
|
||||||
|
parts.push(shaft)
|
||||||
|
const rimSide = d + 2 * rimOverhang
|
||||||
|
const rim = new THREE.BoxGeometry(rimSide, rimHeight, rimSide)
|
||||||
|
rim.translate(x, yShaftTop + rimHeight / 2, 0)
|
||||||
|
parts.push(rim)
|
||||||
|
} else {
|
||||||
|
// Round flues: indexed CylinderGeometry — smooth shafts, crisp
|
||||||
|
// rim edges, radial cap UVs (same #1/#2 fixes already applied to
|
||||||
|
// the body / cap / bands).
|
||||||
|
const shaft = buildSmoothCylinder(yBot, yShaftTop, d / 2, d / 2)
|
||||||
|
shaft.translate(x, 0, 0)
|
||||||
|
parts.push(shaft)
|
||||||
|
const rimR = d / 2 + rimOverhang
|
||||||
|
const rim = buildSmoothCylinder(yShaftTop, yShaftTop + rimHeight, rimR, rimR)
|
||||||
|
rim.translate(x, 0, 0)
|
||||||
|
parts.push(rim)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length === 0) return null
|
||||||
|
const merged = mergeAndDispose(parts)
|
||||||
|
applyNodeTransform(merged, node)
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Cricket ─────────────────────────────────────────────────────────
|
||||||
|
// Water-shedding wedge on the up-slope side of the chimney.
|
||||||
|
|
||||||
|
function buildCricketGeometry(
|
||||||
|
node: ChimneyNode,
|
||||||
|
baseY: number,
|
||||||
|
): THREE.BufferGeometry {
|
||||||
|
const w = node.width
|
||||||
|
const d = node.depth
|
||||||
|
const cL = Math.max(0.1, node.cricketLength)
|
||||||
|
const cH = Math.max(0.05, node.cricketHeight)
|
||||||
|
const slopeSign = node.cricketSide === 'back' ? -1 : 1
|
||||||
|
const sZ = slopeSign * (d / 2)
|
||||||
|
const sZFar = sZ + slopeSign * cL
|
||||||
|
const peakY = baseY + cH
|
||||||
|
const slopeLen = Math.hypot(cL, cH)
|
||||||
|
|
||||||
|
const positions: number[] = []
|
||||||
|
const uvs: number[] = []
|
||||||
|
|
||||||
|
// Vertex layout (back = against the chimney face):
|
||||||
|
// v0/v1 back-bottom (left/right) v4/v5 back-top (left/right)
|
||||||
|
// v3/v2 front-bottom (left/right)
|
||||||
|
const v0: [number, number, number] = [-w / 2, baseY, sZ]
|
||||||
|
const v1: [number, number, number] = [w / 2, baseY, sZ]
|
||||||
|
const v2: [number, number, number] = [w / 2, baseY, sZFar]
|
||||||
|
const v3: [number, number, number] = [-w / 2, baseY, sZFar]
|
||||||
|
const v4: [number, number, number] = [-w / 2, peakY, sZ]
|
||||||
|
const v5: [number, number, number] = [w / 2, peakY, sZ]
|
||||||
|
|
||||||
|
// Planar UVs per face — each face mapped to its own 2D extent so the
|
||||||
|
// texture tiles correctly (u along width, v along the in-face axis).
|
||||||
|
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],
|
||||||
|
}
|
||||||
|
const uvSlope: Record<'v3' | 'v2' | 'v5' | 'v4', [number, number]> = {
|
||||||
|
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],
|
||||||
|
}
|
||||||
|
const uvLeft: Record<'v0' | 'v3' | 'v4', [number, number]> = {
|
||||||
|
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],
|
||||||
|
}
|
||||||
|
|
||||||
|
const pushTri = (
|
||||||
|
a: [number, number, number],
|
||||||
|
b: [number, number, number],
|
||||||
|
c: [number, number, number],
|
||||||
|
ua: [number, number],
|
||||||
|
ub: [number, number],
|
||||||
|
uc: [number, number],
|
||||||
|
) => {
|
||||||
|
if (slopeSign > 0) {
|
||||||
|
positions.push(...a, ...b, ...c)
|
||||||
|
uvs.push(...ua, ...ub, ...uc)
|
||||||
|
} else {
|
||||||
|
positions.push(...a, ...c, ...b)
|
||||||
|
uvs.push(...ua, ...uc, ...ub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom (quad split into 2 tris)
|
||||||
|
pushTri(v0, v1, v2, uvBottom.v0, uvBottom.v1, uvBottom.v2)
|
||||||
|
pushTri(v0, v2, v3, uvBottom.v0, uvBottom.v2, uvBottom.v3)
|
||||||
|
// Sloped top (v3 v2 v5 v4)
|
||||||
|
pushTri(v3, v2, v5, uvSlope.v3, uvSlope.v2, uvSlope.v5)
|
||||||
|
pushTri(v3, v5, v4, uvSlope.v3, uvSlope.v5, uvSlope.v4)
|
||||||
|
// Back face against the chimney (v0 v1 v5 v4)
|
||||||
|
pushTri(v0, v4, v5, uvBack.v0, uvBack.v4, uvBack.v5)
|
||||||
|
pushTri(v0, v5, v1, uvBack.v0, uvBack.v5, uvBack.v1)
|
||||||
|
// Left side triangle
|
||||||
|
pushTri(v0, v3, v4, uvLeft.v0, uvLeft.v3, uvLeft.v4)
|
||||||
|
// Right side triangle
|
||||||
|
pushTri(v1, v5, v2, uvRight.v1, uvRight.v5, uvRight.v2)
|
||||||
|
|
||||||
|
const geo = buildBufferGeometry(positions, uvs)
|
||||||
|
applyNodeTransform(geo, node)
|
||||||
|
geo.computeVertexNormals()
|
||||||
|
return geo
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Bands ───────────────────────────────────────────────────────────
|
||||||
|
// Decorative horizontal stripes around the chimney (soldier-course
|
||||||
|
// brick / stone band). Single or double; each band protrudes outward
|
||||||
|
// by `bandExtent` per side.
|
||||||
|
|
||||||
|
function buildBandsGeometry(
|
||||||
|
node: ChimneyNode,
|
||||||
|
baseY: number,
|
||||||
|
topY: number,
|
||||||
|
): THREE.BufferGeometry | null {
|
||||||
|
const isRound = node.bodyShape === 'round'
|
||||||
|
const w = node.width
|
||||||
|
const d = isRound ? node.width : node.depth
|
||||||
|
const r = w / 2
|
||||||
|
const bandExt = Math.max(0, node.bandExtent)
|
||||||
|
const bandH = Math.max(0.02, node.bandHeight)
|
||||||
|
const bandOffset = Math.max(0, node.bandOffset)
|
||||||
|
const count = node.bandStyle === 'double' ? 2 : 1
|
||||||
|
const gap = bandH * 0.6
|
||||||
|
|
||||||
|
if (isRound) {
|
||||||
|
const parts: THREE.BufferGeometry[] = []
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const bandTop = topY - bandOffset - i * (bandH + gap)
|
||||||
|
const bandBot = bandTop - bandH
|
||||||
|
if (bandBot <= baseY + 0.01) break
|
||||||
|
parts.push(buildSmoothCylinder(bandBot, bandTop, r + bandExt, r + bandExt))
|
||||||
|
}
|
||||||
|
if (parts.length === 0) return null
|
||||||
|
const merged = mergeAndDispose(parts)
|
||||||
|
applyNodeTransform(merged, node)
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
const positions: number[] = []
|
||||||
|
const uvs: number[] = []
|
||||||
|
const bevel = Math.max(0, node.cornerBevel ?? 0)
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const bandTop = topY - bandOffset - i * (bandH + gap)
|
||||||
|
const bandBot = bandTop - bandH
|
||||||
|
if (bandBot <= baseY + 0.01) break
|
||||||
|
pushSlabFaces(
|
||||||
|
positions,
|
||||||
|
uvs,
|
||||||
|
bandBot,
|
||||||
|
bandTop,
|
||||||
|
w / 2 + bandExt,
|
||||||
|
d / 2 + bandExt,
|
||||||
|
w / 2 + bandExt,
|
||||||
|
d / 2 + bandExt,
|
||||||
|
bevel,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (positions.length === 0) return null
|
||||||
|
|
||||||
|
const geo = buildBufferGeometry(positions, uvs)
|
||||||
|
applyNodeTransform(geo, node)
|
||||||
|
geo.computeVertexNormals()
|
||||||
|
return geo
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function applyNodeTransform(geo: THREE.BufferGeometry, node: ChimneyNode) {
|
||||||
|
if (Math.abs(node.rotation) > 1e-4) geo.rotateY(node.rotation)
|
||||||
|
geo.translate(node.position[0] ?? 0, 0, node.position[2] ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBufferGeometry(positions: number[], uvs: number[]): THREE.BufferGeometry {
|
||||||
|
const geo = new THREE.BufferGeometry()
|
||||||
|
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
|
||||||
|
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
|
||||||
|
return geo
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushSlabFaces(
|
||||||
|
positions: number[],
|
||||||
|
uvs: number[],
|
||||||
|
y0: number,
|
||||||
|
y1: number,
|
||||||
|
halfWB: number,
|
||||||
|
halfDB: number,
|
||||||
|
halfWT: number,
|
||||||
|
halfDT: number,
|
||||||
|
bevel = 0,
|
||||||
|
) {
|
||||||
|
// Clamp bevel so it never eats more than the slab can spare on
|
||||||
|
// either ring (a wider bottom plus a narrower top, e.g. an inverted
|
||||||
|
// taper, has different limits per ring).
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = y1 - y0
|
||||||
|
const bBL: [number, number, number] = [-halfWB, y0, -halfDB]
|
||||||
|
const bBR: [number, number, number] = [halfWB, y0, -halfDB]
|
||||||
|
const bTR: [number, number, number] = [halfWB, y0, halfDB]
|
||||||
|
const bTL: [number, number, number] = [-halfWB, y0, halfDB]
|
||||||
|
const tBL: [number, number, number] = [-halfWT, y1, -halfDT]
|
||||||
|
const tBR: [number, number, number] = [halfWT, y1, -halfDT]
|
||||||
|
const tTR: [number, number, number] = [halfWT, y1, halfDT]
|
||||||
|
const tTL: [number, number, number] = [-halfWT, y1, halfDT]
|
||||||
|
|
||||||
|
const pushQuad = (
|
||||||
|
a: [number, number, number],
|
||||||
|
b: [number, number, number],
|
||||||
|
c: [number, number, number],
|
||||||
|
d: [number, number, number],
|
||||||
|
ua: [number, number],
|
||||||
|
ub: [number, number],
|
||||||
|
uc: [number, number],
|
||||||
|
ud: [number, number],
|
||||||
|
) => {
|
||||||
|
positions.push(...a, ...c, ...b, ...a, ...d, ...c)
|
||||||
|
uvs.push(...ua, ...uc, ...ub, ...ua, ...ud, ...uc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom
|
||||||
|
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])
|
||||||
|
// 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])
|
||||||
|
pushQuad(bTR, bTL, tTL, tTR, [halfWB, 0], [-halfWB, 0], [-halfWT, t], [halfWT, t])
|
||||||
|
pushQuad(bTL, bBL, tBL, tTL, [halfDB, 0], [-halfDB, 0], [-halfDT, t], [halfDT, t])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Octagonal-footprint variant of `pushSlabFaces`. Each corner of the
|
||||||
|
* usual 4-corner slab is replaced by a 45° chamfer, giving an
|
||||||
|
* 8-vertex ring at each y-level. Eight side faces (four axis-aligned
|
||||||
|
* + four chamfer) plus two fan-triangulated octagonal caps. UVs use
|
||||||
|
* the same physical-meter convention as the unchamfered path so
|
||||||
|
* textures (brick, stone) tile at a consistent rate either way.
|
||||||
|
*/
|
||||||
|
function pushOctagonalSlabFaces(
|
||||||
|
positions: number[],
|
||||||
|
uvs: number[],
|
||||||
|
y0: number,
|
||||||
|
y1: number,
|
||||||
|
halfWB: number,
|
||||||
|
halfDB: number,
|
||||||
|
halfWT: number,
|
||||||
|
halfDT: number,
|
||||||
|
cB: number,
|
||||||
|
cT: number,
|
||||||
|
) {
|
||||||
|
// Eight ring vertices per y-level, traced so consecutive entries
|
||||||
|
// share an outward-facing wall edge. Order (looking down +Y):
|
||||||
|
// p0 (+x, -z+c) p1 (+x, +z-c) p2 (+x-c, +z) p3 (-x+c, +z)
|
||||||
|
// p4 (-x, +z-c) p5 (-x, -z+c) p6 (-x+c, -z) p7 (+x-c, -z)
|
||||||
|
const ring = (hw: number, hd: number, c: number, y: number) =>
|
||||||
|
[
|
||||||
|
[hw, y, -hd + c],
|
||||||
|
[hw, y, hd - c],
|
||||||
|
[hw - c, y, hd],
|
||||||
|
[-hw + c, y, hd],
|
||||||
|
[-hw, y, hd - c],
|
||||||
|
[-hw, y, -hd + c],
|
||||||
|
[-hw + c, y, -hd],
|
||||||
|
[hw - c, y, -hd],
|
||||||
|
] as Array<[number, number, number]>
|
||||||
|
|
||||||
|
const bot = ring(halfWB, halfDB, cB, y0)
|
||||||
|
const top = ring(halfWT, halfDT, cT, y1)
|
||||||
|
const t = y1 - y0
|
||||||
|
|
||||||
|
// Eight walls. UVs: u = signed perimeter offset (in meters) from
|
||||||
|
// the start of each wall, v = height.
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
const j = (i + 1) % 8
|
||||||
|
const bA = bot[i]!
|
||||||
|
const bB = bot[j]!
|
||||||
|
const tA = top[i]!
|
||||||
|
const tB = top[j]!
|
||||||
|
const wallLen = Math.hypot(bB[0] - bA[0], bB[2] - bA[2])
|
||||||
|
// Two CCW-from-outside triangles per quad: (bA, bB, tB) + (bA, tB, tA).
|
||||||
|
positions.push(...bA, ...bB, ...tB, ...bA, ...tB, ...tA)
|
||||||
|
uvs.push(0, 0, wallLen, 0, wallLen, t, 0, 0, wallLen, t, 0, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top cap: fan from centre. CCW from above → +Y normal.
|
||||||
|
const cTop: [number, number, number] = [0, y1, 0]
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
const j = (i + 1) % 8
|
||||||
|
const a = top[i]!
|
||||||
|
const b = top[j]!
|
||||||
|
positions.push(...cTop, ...b, ...a)
|
||||||
|
uvs.push(0, 0, b[0], b[2], a[0], a[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom cap: reverse winding → -Y normal.
|
||||||
|
const cBot: [number, number, number] = [0, y0, 0]
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
const j = (i + 1) % 8
|
||||||
|
const a = bot[i]!
|
||||||
|
const b = bot[j]!
|
||||||
|
positions.push(...cBot, ...a, ...b)
|
||||||
|
uvs.push(0, 0, a[0], a[2], b[0], b[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
import { type ChimneyNode, getActiveRoofHeight, type RoofSegmentNode } from '@pascal-app/core'
|
||||||
|
import {
|
||||||
|
Brush,
|
||||||
|
csgEvaluator,
|
||||||
|
csgGeometry,
|
||||||
|
prepareBrushForCSG,
|
||||||
|
SUBTRACTION,
|
||||||
|
} from '@pascal-app/viewer'
|
||||||
|
import * as THREE from 'three'
|
||||||
|
import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
||||||
|
import { flueXPositions } from './geometry'
|
||||||
|
|
||||||
|
const dummyMat = new THREE.MeshBasicMaterial()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carve the top openings the chimney needs:
|
||||||
|
* - smoke shaft cavity in the body (one chimney-wide hole, or one
|
||||||
|
* bore per flue when the flues are hollow)
|
||||||
|
* - matching holes punched through the cap
|
||||||
|
* - inner bore subtracted from each flue tube
|
||||||
|
*
|
||||||
|
* Mirrors the v1 roof-system pipeline. Lives next to `roof-trim.ts` so
|
||||||
|
* the CSG deps (three-bvh-csg / three-mesh-bvh) stay inside the
|
||||||
|
* chimney folder; `geometry.ts` itself stays pure.
|
||||||
|
*/
|
||||||
|
export function carveChimneyHoles(
|
||||||
|
body: THREE.BufferGeometry,
|
||||||
|
cap: THREE.BufferGeometry | null,
|
||||||
|
flues: THREE.BufferGeometry | null,
|
||||||
|
node: ChimneyNode,
|
||||||
|
segment: RoofSegmentNode,
|
||||||
|
): {
|
||||||
|
body: THREE.BufferGeometry
|
||||||
|
cap: THREE.BufferGeometry | null
|
||||||
|
flues: THREE.BufferGeometry | null
|
||||||
|
} {
|
||||||
|
const peakY = segment.wallHeight + getActiveRoofHeight(segment)
|
||||||
|
const topY = peakY + node.heightAboveRidge
|
||||||
|
const capPresent = !!cap && node.cap && node.capShape !== 'none'
|
||||||
|
const capTopY = topY + (capPresent ? node.capThickness : 0)
|
||||||
|
|
||||||
|
const flueCount = Math.max(0, Math.min(4, node.flueCount ?? 0))
|
||||||
|
const flueDiameter = Math.max(0.02, node.flueDiameter ?? 0.22)
|
||||||
|
const flueWallT = Math.max(0, node.flueWallThickness ?? 0.02)
|
||||||
|
const flueInner = flueDiameter - 2 * flueWallT
|
||||||
|
const useFlueHoles = flueCount > 0 && flueWallT > 0 && flueInner > 0.02
|
||||||
|
|
||||||
|
const cavityDepth = Math.max(0, node.bodyHollowDepth ?? 0.6)
|
||||||
|
const hollowMargin = Math.max(0, node.bodyHollowMargin ?? 0.08)
|
||||||
|
const isRound = (node.bodyShape ?? 'square') === 'round'
|
||||||
|
|
||||||
|
type CutterSpec = {
|
||||||
|
shape: 'round' | 'square'
|
||||||
|
sizeX: number
|
||||||
|
sizeZ: number
|
||||||
|
xCenter: number
|
||||||
|
}
|
||||||
|
const specs: CutterSpec[] = []
|
||||||
|
if (cavityDepth > 0.01) {
|
||||||
|
if (useFlueHoles) {
|
||||||
|
const xs = flueXPositions(flueCount, node.width, flueDiameter, node.flueSpacing)
|
||||||
|
const flueShape = node.flueShape ?? 'round'
|
||||||
|
for (const x of xs) {
|
||||||
|
specs.push({ shape: flueShape, sizeX: flueInner, sizeZ: flueInner, xCenter: x })
|
||||||
|
}
|
||||||
|
} else if (hollowMargin > 0) {
|
||||||
|
if (isRound) {
|
||||||
|
const r = node.width / 2 - hollowMargin
|
||||||
|
if (r > 0.02) {
|
||||||
|
specs.push({ shape: 'round', sizeX: 2 * r, sizeZ: 2 * r, xCenter: 0 })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const cw = node.width - 2 * hollowMargin
|
||||||
|
const cd = node.depth - 2 * hollowMargin
|
||||||
|
if (cw > 0.04 && cd > 0.04) {
|
||||||
|
specs.push({ shape: 'square', sizeX: cw, sizeZ: cd, xCenter: 0 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const flueHeight = Math.max(0.02, node.flueHeight ?? 0.3)
|
||||||
|
const yCavityBot = topY - cavityDepth
|
||||||
|
const yCapTop = capTopY + 0.02
|
||||||
|
|
||||||
|
const subtractFrom = (
|
||||||
|
base: THREE.BufferGeometry,
|
||||||
|
yBot: number,
|
||||||
|
yTop: number,
|
||||||
|
): THREE.BufferGeometry => {
|
||||||
|
if (specs.length === 0) return base
|
||||||
|
const cutters = specs.map((spec) => buildCutter(node, spec, yBot, yTop))
|
||||||
|
const result = subtractCutters(base, cutters)
|
||||||
|
for (const cutter of cutters) cutter.geometry.dispose()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
let newBody = subtractFrom(body, yCavityBot, yCapTop)
|
||||||
|
const newCap = cap ? subtractFrom(cap, yCavityBot, yCapTop) : null
|
||||||
|
|
||||||
|
// Decorative inset panels — carve a shallow rectangle out of each
|
||||||
|
// vertical face. Square bodies only (round bodies have no flat
|
||||||
|
// faces). Same CSG pipeline as the cavity cutters above.
|
||||||
|
const wantPanels =
|
||||||
|
node.panelStyle !== 'none' && !isRound && node.panelDepth > 0 && node.panelHeight > 0.01
|
||||||
|
if (wantPanels) {
|
||||||
|
const panelCutters = buildPanelCutters(node, topY)
|
||||||
|
if (panelCutters.length > 0) {
|
||||||
|
newBody = subtractCutters(newBody, panelCutters)
|
||||||
|
for (const cutter of panelCutters) cutter.geometry.dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hollow each flue tube by punching its inner bore through.
|
||||||
|
let newFlues = flues
|
||||||
|
if (flues && useFlueHoles) {
|
||||||
|
const xs = flueXPositions(flueCount, node.width, flueDiameter, node.flueSpacing)
|
||||||
|
const flueShape = node.flueShape ?? 'round'
|
||||||
|
const cutters = xs.map((x) =>
|
||||||
|
buildCutter(
|
||||||
|
node,
|
||||||
|
{ shape: flueShape, sizeX: flueInner, sizeZ: flueInner, xCenter: x },
|
||||||
|
capTopY - 0.02,
|
||||||
|
capTopY + flueHeight + 0.02,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
newFlues = subtractCutters(flues, cutters)
|
||||||
|
for (const cutter of cutters) cutter.geometry.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Partition each surface so the very top face becomes its own material
|
||||||
|
// group (index 1 → top material, index 0 → body material). Matches v1's
|
||||||
|
// surface-array assignment.
|
||||||
|
partitionTopFaceGroups(newBody, topY - 0.05)
|
||||||
|
if (newCap) partitionTopFaceGroups(newCap, capTopY - 0.005)
|
||||||
|
if (newFlues) partitionTopFaceGroups(newFlues, capTopY + flueHeight - 0.005)
|
||||||
|
|
||||||
|
return { body: newBody, cap: newCap, flues: newFlues }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split the index buffer into two groups:
|
||||||
|
* - group 0: every triangle whose normal is NOT roughly up, or that
|
||||||
|
* sits below `topYMin`. Receives the body material.
|
||||||
|
* - group 1: the top face triangles. Receives the top material.
|
||||||
|
*
|
||||||
|
* Mirrors the v1 `partitionTopFaceGroups` in
|
||||||
|
* `packages/viewer/src/systems/chimney/chimney-geometry.ts`. Operates in
|
||||||
|
* place — the geometry is re-indexed and its `groups` array rewritten.
|
||||||
|
*/
|
||||||
|
export function partitionTopFaceGroups(geo: THREE.BufferGeometry, topYMin: number) {
|
||||||
|
// CSG paths return indexed geometry; the pure-builder path doesn't. If
|
||||||
|
// we don't have an index, build one so the partitioning logic has
|
||||||
|
// something to reorder.
|
||||||
|
if (!geo.getIndex()) {
|
||||||
|
const merged = mergeVertices(geo, 1e-4)
|
||||||
|
if (merged.getIndex()) {
|
||||||
|
const idx = merged.getIndex()!
|
||||||
|
geo.setIndex(idx)
|
||||||
|
geo.setAttribute('position', merged.getAttribute('position'))
|
||||||
|
if (merged.getAttribute('uv')) geo.setAttribute('uv', merged.getAttribute('uv'))
|
||||||
|
if (merged.getAttribute('normal')) geo.setAttribute('normal', merged.getAttribute('normal'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const positions = geo.getAttribute('position')
|
||||||
|
let normals = geo.getAttribute('normal')
|
||||||
|
if (!normals) {
|
||||||
|
geo.computeVertexNormals()
|
||||||
|
normals = geo.getAttribute('normal')
|
||||||
|
}
|
||||||
|
const index = geo.getIndex()
|
||||||
|
if (!(positions && normals && index)) {
|
||||||
|
geo.clearGroups()
|
||||||
|
geo.addGroup(0, index?.count ?? positions.count, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const idxArr = index.array as ArrayLike<number>
|
||||||
|
const topTris: number[] = []
|
||||||
|
const otherTris: number[] = []
|
||||||
|
const yEps = 0.02
|
||||||
|
|
||||||
|
for (let i = 0; i < idxArr.length; i += 3) {
|
||||||
|
const a = idxArr[i] as number
|
||||||
|
const b = idxArr[i + 1] as number
|
||||||
|
const c = idxArr[i + 2] as number
|
||||||
|
const ny = (normals.getY(a) + normals.getY(b) + normals.getY(c)) / 3
|
||||||
|
const py = (positions.getY(a) + positions.getY(b) + positions.getY(c)) / 3
|
||||||
|
if (ny > 0.95 && py >= topYMin - yEps) {
|
||||||
|
topTris.push(a, b, c)
|
||||||
|
} else {
|
||||||
|
otherTris.push(a, b, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = otherTris.length + topTris.length
|
||||||
|
const useUint32 = (positions.count ?? 0) > 0xff_ff
|
||||||
|
const newArr = useUint32 ? new Uint32Array(total) : new Uint16Array(total)
|
||||||
|
for (let i = 0; i < otherTris.length; i++) newArr[i] = otherTris[i] as number
|
||||||
|
for (let i = 0; i < topTris.length; i++) newArr[otherTris.length + i] = topTris[i] as number
|
||||||
|
geo.setIndex(new THREE.BufferAttribute(newArr, 1))
|
||||||
|
|
||||||
|
geo.clearGroups()
|
||||||
|
if (otherTris.length > 0) geo.addGroup(0, otherTris.length, 0)
|
||||||
|
if (topTris.length > 0) geo.addGroup(otherTris.length, topTris.length, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build one cutter brush per vertical face for the inset-panel feature.
|
||||||
|
* Each cutter is a thin box flush against the body face; CSG-subtracted
|
||||||
|
* from the body it leaves a recessed rectangular panel — same shape as
|
||||||
|
* v1's `buildPanelCutterBrush`.
|
||||||
|
*/
|
||||||
|
function buildPanelCutters(node: ChimneyNode, topY: number): Brush[] {
|
||||||
|
const w = node.width
|
||||||
|
const d = node.depth
|
||||||
|
const margin = Math.max(0, node.panelMargin)
|
||||||
|
const recess = Math.max(0.005, node.panelDepth)
|
||||||
|
const panelHeight = Math.max(0.05, node.panelHeight)
|
||||||
|
const offsetTop = Math.max(0, node.panelOffsetTop)
|
||||||
|
const yTop = topY - offsetTop
|
||||||
|
const yBot = yTop - panelHeight
|
||||||
|
const eps = 0.002
|
||||||
|
|
||||||
|
const faces: Array<{
|
||||||
|
sizeX: number
|
||||||
|
sizeZ: number
|
||||||
|
cx: number
|
||||||
|
cz: number
|
||||||
|
}> = []
|
||||||
|
|
||||||
|
const panelW = w - 2 * margin
|
||||||
|
const panelD = d - 2 * margin
|
||||||
|
if (panelW > 0.05) {
|
||||||
|
// frontZ
|
||||||
|
faces.push({
|
||||||
|
sizeX: panelW,
|
||||||
|
sizeZ: recess + 2 * eps,
|
||||||
|
cx: 0,
|
||||||
|
cz: d / 2 - recess / 2 + eps,
|
||||||
|
})
|
||||||
|
// backZ
|
||||||
|
faces.push({
|
||||||
|
sizeX: panelW,
|
||||||
|
sizeZ: recess + 2 * eps,
|
||||||
|
cx: 0,
|
||||||
|
cz: -d / 2 + recess / 2 - eps,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (panelD > 0.05) {
|
||||||
|
// rightX
|
||||||
|
faces.push({
|
||||||
|
sizeX: recess + 2 * eps,
|
||||||
|
sizeZ: panelD,
|
||||||
|
cx: w / 2 - recess / 2 + eps,
|
||||||
|
cz: 0,
|
||||||
|
})
|
||||||
|
// leftX
|
||||||
|
faces.push({
|
||||||
|
sizeX: recess + 2 * eps,
|
||||||
|
sizeZ: panelD,
|
||||||
|
cx: -w / 2 + recess / 2 - eps,
|
||||||
|
cz: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const h = Math.max(0.02, yTop - yBot)
|
||||||
|
const midY = (yTop + yBot) / 2
|
||||||
|
const brushes: Brush[] = []
|
||||||
|
for (const f of faces) {
|
||||||
|
const geo = new THREE.BoxGeometry(f.sizeX, h, f.sizeZ)
|
||||||
|
geo.translate(f.cx, midY, f.cz)
|
||||||
|
if (Math.abs(node.rotation) > 1e-4) geo.rotateY(node.rotation)
|
||||||
|
geo.translate(node.position[0] ?? 0, 0, node.position[2] ?? 0)
|
||||||
|
|
||||||
|
const idx = geo.getIndex()?.count ?? 0
|
||||||
|
geo.clearGroups()
|
||||||
|
if (idx > 0) geo.addGroup(0, idx, 0)
|
||||||
|
|
||||||
|
const brush = new Brush(geo, dummyMat as unknown as THREE.MeshStandardMaterial)
|
||||||
|
brush.updateMatrixWorld()
|
||||||
|
prepareBrushForCSG(brush)
|
||||||
|
brushes.push(brush)
|
||||||
|
}
|
||||||
|
return brushes
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCutter(
|
||||||
|
node: ChimneyNode,
|
||||||
|
spec: { shape: 'round' | 'square'; sizeX: number; sizeZ: number; xCenter: number },
|
||||||
|
yBot: number,
|
||||||
|
yTop: number,
|
||||||
|
): Brush {
|
||||||
|
const h = Math.max(0.02, yTop - yBot)
|
||||||
|
const midY = (yTop + yBot) / 2
|
||||||
|
const geo: THREE.BufferGeometry =
|
||||||
|
spec.shape === 'round'
|
||||||
|
? new THREE.CylinderGeometry(spec.sizeX / 2, spec.sizeX / 2, h, 24, 1, false)
|
||||||
|
: new THREE.BoxGeometry(spec.sizeX, h, spec.sizeZ)
|
||||||
|
geo.translate(spec.xCenter, midY, 0)
|
||||||
|
// Match the same node-local transform that `geometry.ts:applyNodeTransform`
|
||||||
|
// bakes into the body/cap/flue vertices.
|
||||||
|
if (Math.abs(node.rotation) > 1e-4) geo.rotateY(node.rotation)
|
||||||
|
geo.translate(node.position[0] ?? 0, 0, node.position[2] ?? 0)
|
||||||
|
|
||||||
|
const idx = geo.getIndex()?.count ?? 0
|
||||||
|
geo.clearGroups()
|
||||||
|
if (idx > 0) geo.addGroup(0, idx, 0)
|
||||||
|
|
||||||
|
const brush = new Brush(geo, dummyMat as unknown as THREE.MeshStandardMaterial)
|
||||||
|
brush.updateMatrixWorld()
|
||||||
|
prepareBrushForCSG(brush)
|
||||||
|
return brush
|
||||||
|
}
|
||||||
|
|
||||||
|
function subtractCutters(
|
||||||
|
base: THREE.BufferGeometry,
|
||||||
|
cutters: Brush[],
|
||||||
|
): THREE.BufferGeometry {
|
||||||
|
if (cutters.length === 0) return base
|
||||||
|
|
||||||
|
const indexed = mergeVertices(base, 1e-4)
|
||||||
|
if (!indexed.getAttribute('normal')) indexed.computeVertexNormals()
|
||||||
|
const ic = indexed.getIndex()?.count ?? 0
|
||||||
|
indexed.clearGroups()
|
||||||
|
if (ic > 0) indexed.addGroup(0, ic, 0)
|
||||||
|
|
||||||
|
const baseBrush = new Brush(indexed, dummyMat as unknown as THREE.MeshStandardMaterial)
|
||||||
|
baseBrush.updateMatrixWorld()
|
||||||
|
prepareBrushForCSG(baseBrush)
|
||||||
|
|
||||||
|
let current: Brush = baseBrush
|
||||||
|
const intermediates: Brush[] = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const cutter of cutters) {
|
||||||
|
const next = csgEvaluator.evaluate(current, cutter, SUBTRACTION) as Brush
|
||||||
|
prepareBrushForCSG(next)
|
||||||
|
if (current !== baseBrush) intermediates.push(current)
|
||||||
|
current = next
|
||||||
|
}
|
||||||
|
const out = csgGeometry(current).clone()
|
||||||
|
const idx = out.getIndex()?.count ?? 0
|
||||||
|
out.clearGroups()
|
||||||
|
if (idx > 0) out.addGroup(0, idx, 0)
|
||||||
|
else out.addGroup(0, out.getAttribute('position').count, 0)
|
||||||
|
out.computeVertexNormals()
|
||||||
|
|
||||||
|
base.dispose()
|
||||||
|
indexed.dispose()
|
||||||
|
for (const b of intermediates) b.geometry.dispose()
|
||||||
|
if (current !== baseBrush) current.geometry.dispose()
|
||||||
|
return out
|
||||||
|
} catch (e) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('[chimney] hole carve CSG failed:', e)
|
||||||
|
indexed.dispose()
|
||||||
|
for (const b of intermediates) b.geometry.dispose()
|
||||||
|
if (current !== baseBrush) current.geometry.dispose()
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { chimneyDefinition } from './definition'
|
||||||
|
export { buildChimneyGeometry, flueXPositions } from './geometry'
|
||||||
|
export { ChimneyNode } from './schema'
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import {
|
||||||
|
type AnyNodeId,
|
||||||
|
ChimneyNode as ChimneyNodeSchema,
|
||||||
|
type ChimneyNode,
|
||||||
|
emitter,
|
||||||
|
type RoofEvent,
|
||||||
|
type RoofNode,
|
||||||
|
type RoofSegmentNode,
|
||||||
|
sceneRegistry,
|
||||||
|
useScene,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import { triggerSFX, useEditor } from '@pascal-app/editor'
|
||||||
|
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 ChimneyPreview from './preview'
|
||||||
|
|
||||||
|
const tmpMatrix = new THREE.Matrix4()
|
||||||
|
const tmpInv = new THREE.Matrix4()
|
||||||
|
const tmpPos = new THREE.Vector3()
|
||||||
|
const tmpQuat = new THREE.Quaternion()
|
||||||
|
const tmpScale = new THREE.Vector3()
|
||||||
|
|
||||||
|
type SegmentTransform = {
|
||||||
|
position: [number, number, number]
|
||||||
|
quaternion: [number, number, number, number]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drag-to-place tool for chimney duplicate / move. Receives the moving
|
||||||
|
* node (a clone with `id` stripped + `metadata.isNew = true` after a
|
||||||
|
* Duplicate action) via `node` prop, shows the same ghost preview as
|
||||||
|
* placement, and on click commits the cloned chimney to the hit
|
||||||
|
* segment with that segment's local coords.
|
||||||
|
*
|
||||||
|
* Mirrors `tool.tsx`'s placement preview — the only differences are
|
||||||
|
* (a) the ghost is built from the moving node so the duplicate
|
||||||
|
* preserves the original's body shape/material/etc., and (b) on click
|
||||||
|
* we keep all of the clone's fields and only overwrite host segment +
|
||||||
|
* position. Mounted via `def.affordanceTools.move`.
|
||||||
|
*/
|
||||||
|
const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => {
|
||||||
|
const activeBuildingId = useViewer((s) => s.selection.buildingId)
|
||||||
|
const setSelection = useViewer((s) => s.setSelection)
|
||||||
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||||
|
|
||||||
|
const [segmentXform, setSegmentXform] = useState<SegmentTransform | null>(null)
|
||||||
|
const [hitLocal, setHitLocal] = useState<[number, number, number] | null>(null)
|
||||||
|
const [previewSegment, setPreviewSegment] = useState<RoofSegmentNode | null>(null)
|
||||||
|
const lastSnapRef = useRef<[number, number] | null>(null)
|
||||||
|
|
||||||
|
// Ghost data — same as the moving clone but pinned to position[0,0,0]
|
||||||
|
// (the inner group does the cursor offset). Reparse so Zod fills any
|
||||||
|
// defaults missing from the clone.
|
||||||
|
const previewNode = useMemo(
|
||||||
|
() =>
|
||||||
|
ChimneyNodeSchema.parse({
|
||||||
|
...node,
|
||||||
|
id: 'chimney_preview' as never,
|
||||||
|
position: [0, 0, 0],
|
||||||
|
rotation: 0,
|
||||||
|
}),
|
||||||
|
[node],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeBuildingId) return
|
||||||
|
|
||||||
|
const computeSegmentXform = (segmentId: string): SegmentTransform | null => {
|
||||||
|
const buildingObj = sceneRegistry.nodes.get(activeBuildingId as AnyNodeId)
|
||||||
|
const segObj = sceneRegistry.nodes.get(segmentId as AnyNodeId)
|
||||||
|
if (!(buildingObj && segObj)) return null
|
||||||
|
buildingObj.updateWorldMatrix(true, false)
|
||||||
|
segObj.updateWorldMatrix(true, false)
|
||||||
|
tmpInv.copy(buildingObj.matrixWorld).invert()
|
||||||
|
tmpMatrix.multiplyMatrices(tmpInv, segObj.matrixWorld)
|
||||||
|
tmpMatrix.decompose(tmpPos, tmpQuat, tmpScale)
|
||||||
|
return {
|
||||||
|
position: [tmpPos.x, tmpPos.y, tmpPos.z],
|
||||||
|
quaternion: [tmpQuat.x, tmpQuat.y, tmpQuat.z, tmpQuat.w],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePreview = (event: RoofEvent) => {
|
||||||
|
const wx = event.position[0]
|
||||||
|
const wy = event.position[1]
|
||||||
|
const wz = event.position[2]
|
||||||
|
|
||||||
|
const sx = Math.round(wx * 20) / 20
|
||||||
|
const sz = Math.round(wz * 20) / 20
|
||||||
|
const prev = lastSnapRef.current
|
||||||
|
if (!prev || prev[0] !== sx || prev[1] !== sz) {
|
||||||
|
triggerSFX('sfx:grid-snap')
|
||||||
|
lastSnapRef.current = [sx, sz]
|
||||||
|
}
|
||||||
|
|
||||||
|
const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz)
|
||||||
|
if (!hit) return
|
||||||
|
|
||||||
|
const xform = computeSegmentXform(hit.segment.id)
|
||||||
|
if (!xform) return
|
||||||
|
setSegmentXform(xform)
|
||||||
|
setHitLocal([hit.localX, hit.localY, hit.localZ])
|
||||||
|
setPreviewSegment(hit.segment)
|
||||||
|
event.stopPropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onClick = (event: RoofEvent) => {
|
||||||
|
const hit = resolveRoofSegmentHit(
|
||||||
|
event.node as RoofNode,
|
||||||
|
event.position[0],
|
||||||
|
event.position[1],
|
||||||
|
event.position[2],
|
||||||
|
)
|
||||||
|
if (!hit) return
|
||||||
|
const state = useScene.getState()
|
||||||
|
|
||||||
|
// Strip the `isNew` flag — only used to mark a duplicate clone
|
||||||
|
// that hasn't been committed yet.
|
||||||
|
const meta =
|
||||||
|
node.metadata && typeof node.metadata === 'object' && !Array.isArray(node.metadata)
|
||||||
|
? (node.metadata as Record<string, unknown>)
|
||||||
|
: {}
|
||||||
|
const { isNew, ...restMeta } = meta as { isNew?: boolean }
|
||||||
|
const cleanedMeta = Object.keys(restMeta).length > 0 ? restMeta : undefined
|
||||||
|
|
||||||
|
// Duplicate (clone with no committed id yet) → create a fresh
|
||||||
|
// chimney parented to the hit segment. Plain move (existing id,
|
||||||
|
// no `isNew` flag) → update host + position in place. Either way
|
||||||
|
// every other field from the clone is preserved.
|
||||||
|
if (isNew || !node.id) {
|
||||||
|
const committed = ChimneyNodeSchema.parse({
|
||||||
|
...node,
|
||||||
|
id: undefined as never,
|
||||||
|
roofSegmentId: hit.segment.id,
|
||||||
|
position: [hit.localX, hit.localY, hit.localZ],
|
||||||
|
metadata: cleanedMeta,
|
||||||
|
})
|
||||||
|
state.createNode(committed, hit.segment.id as AnyNodeId)
|
||||||
|
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
||||||
|
setSelection({ selectedIds: [committed.id] })
|
||||||
|
} else {
|
||||||
|
const prevSegmentId = node.roofSegmentId as AnyNodeId | undefined
|
||||||
|
state.updateNode(node.id as AnyNodeId, {
|
||||||
|
roofSegmentId: hit.segment.id,
|
||||||
|
parentId: hit.segment.id,
|
||||||
|
position: [hit.localX, hit.localY, hit.localZ],
|
||||||
|
metadata: cleanedMeta,
|
||||||
|
})
|
||||||
|
if (prevSegmentId) state.dirtyNodes.add(prevSegmentId)
|
||||||
|
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
||||||
|
setSelection({ selectedIds: [node.id] })
|
||||||
|
}
|
||||||
|
setMovingNode(null)
|
||||||
|
triggerSFX('sfx:item-place')
|
||||||
|
event.stopPropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
emitter.on('roof:move', updatePreview)
|
||||||
|
emitter.on('roof:enter', updatePreview)
|
||||||
|
emitter.on('roof:click', onClick)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
emitter.off('roof:move', updatePreview)
|
||||||
|
emitter.off('roof:enter', updatePreview)
|
||||||
|
emitter.off('roof:click', onClick)
|
||||||
|
}
|
||||||
|
}, [activeBuildingId, node, setMovingNode, setSelection])
|
||||||
|
|
||||||
|
if (!activeBuildingId || !segmentXform || !hitLocal || !previewSegment) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group position={segmentXform.position} quaternion={segmentXform.quaternion}>
|
||||||
|
<group position={[hitLocal[0], 0, hitLocal[2]]}>
|
||||||
|
<ChimneyPreview node={previewNode} segment={previewSegment} />
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MoveChimneyTool
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import type {
|
||||||
|
ChimneyMaterialRole,
|
||||||
|
ChimneyNode,
|
||||||
|
MaterialSchema,
|
||||||
|
PaintCapability,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import { createMaterial, createMaterialFromPresetRef } from '@pascal-app/viewer'
|
||||||
|
import type { Material, Mesh } from 'three'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a chimney face click to its logical surface role.
|
||||||
|
*
|
||||||
|
* `holes.ts:partitionTopFaceGroups` partitions the chimney body
|
||||||
|
* mesh's material slots so:
|
||||||
|
* 0 = body
|
||||||
|
* 1 = top (the cap face)
|
||||||
|
* Faces outside any group (cricket mesh, which renders with a single
|
||||||
|
* material) fall back to 'body'.
|
||||||
|
*/
|
||||||
|
export function resolveChimneyRole(materialIndex: number | null): ChimneyMaterialRole {
|
||||||
|
return materialIndex === 1 ? 'top' : 'body'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildChimneyMaterialPatch(
|
||||||
|
role: ChimneyMaterialRole,
|
||||||
|
material: MaterialSchema | undefined,
|
||||||
|
materialPreset: string | undefined,
|
||||||
|
): Partial<ChimneyNode> {
|
||||||
|
if (role === 'top') {
|
||||||
|
return { topMaterial: material, topMaterialPreset: materialPreset }
|
||||||
|
}
|
||||||
|
return { material, materialPreset }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEffectiveChimneyMaterial(
|
||||||
|
node: ChimneyNode,
|
||||||
|
role: ChimneyMaterialRole,
|
||||||
|
): { material: MaterialSchema | undefined; materialPreset: string | undefined } {
|
||||||
|
if (role === 'top') {
|
||||||
|
const hasTop = node.topMaterial !== undefined || node.topMaterialPreset !== undefined
|
||||||
|
if (hasTop) {
|
||||||
|
return { material: node.topMaterial, materialPreset: node.topMaterialPreset }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { material: node.material, materialPreset: node.materialPreset }
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPreviewMaterial(
|
||||||
|
material: MaterialSchema | undefined,
|
||||||
|
materialPreset: string | undefined,
|
||||||
|
): Material | null {
|
||||||
|
if (materialPreset) {
|
||||||
|
return createMaterialFromPresetRef(materialPreset)
|
||||||
|
}
|
||||||
|
if (material) {
|
||||||
|
return createMaterial(material)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a preview material to the chimney's mesh subtree for the
|
||||||
|
* given role. The body mesh uses a 2-slot material array (body =
|
||||||
|
* slot 0, top = slot 1) so paint-target → slot index is a single
|
||||||
|
* lookup. The cricket mesh uses a single material, which only the
|
||||||
|
* 'body' role paints.
|
||||||
|
*/
|
||||||
|
function applyChimneyPreview(
|
||||||
|
role: ChimneyMaterialRole,
|
||||||
|
previewMaterial: Material,
|
||||||
|
root: import('three').Object3D,
|
||||||
|
): (() => void) | null {
|
||||||
|
const restores: Array<() => void> = []
|
||||||
|
root.traverse((object) => {
|
||||||
|
const mesh = object as Mesh
|
||||||
|
if (!mesh.isMesh) return
|
||||||
|
const current = mesh.material as Material | Material[]
|
||||||
|
if (Array.isArray(current)) {
|
||||||
|
const idx = role === 'top' ? 1 : 0
|
||||||
|
const previousAtIdx = current[idx]
|
||||||
|
if (!previousAtIdx) return
|
||||||
|
const previousArray = [...current]
|
||||||
|
const nextArray = [...current]
|
||||||
|
nextArray[idx] = previewMaterial
|
||||||
|
mesh.material = nextArray
|
||||||
|
restores.push(() => {
|
||||||
|
mesh.material = previousArray
|
||||||
|
})
|
||||||
|
} else if (role === 'body') {
|
||||||
|
const previous = mesh.material
|
||||||
|
mesh.material = previewMaterial
|
||||||
|
restores.push(() => {
|
||||||
|
mesh.material = previous
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (restores.length === 0) return null
|
||||||
|
return () => {
|
||||||
|
for (let i = restores.length - 1; i >= 0; i -= 1) restores[i]?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capability binding for the chimney kind. The editor's
|
||||||
|
* selection-manager invokes these in place of the legacy
|
||||||
|
* `if (node.type === 'chimney') { ... }` arm.
|
||||||
|
*/
|
||||||
|
export const chimneyPaint: PaintCapability = {
|
||||||
|
resolveRole: ({ materialIndex }) => resolveChimneyRole(materialIndex),
|
||||||
|
buildPatch: ({ role, material, materialPreset }) =>
|
||||||
|
buildChimneyMaterialPatch(role as ChimneyMaterialRole, material, materialPreset),
|
||||||
|
applyPreview: ({ role, material, materialPreset, root }) => {
|
||||||
|
const previewMaterial = buildPreviewMaterial(material, materialPreset)
|
||||||
|
if (!previewMaterial) return null
|
||||||
|
return applyChimneyPreview(role as ChimneyMaterialRole, previewMaterial, root)
|
||||||
|
},
|
||||||
|
getEffectiveMaterial: ({ node, role }) =>
|
||||||
|
getEffectiveChimneyMaterial(node as ChimneyNode, role as ChimneyMaterialRole),
|
||||||
|
}
|
||||||
@@ -0,0 +1,904 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import {
|
||||||
|
type AnyNode,
|
||||||
|
type AnyNodeId,
|
||||||
|
type ChimneyNode,
|
||||||
|
getActiveRoofHeight,
|
||||||
|
type RoofNode,
|
||||||
|
type RoofSegmentNode,
|
||||||
|
sceneRegistry,
|
||||||
|
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,
|
||||||
|
PanelSection,
|
||||||
|
PanelWrapper,
|
||||||
|
SegmentedControl,
|
||||||
|
SliderControl,
|
||||||
|
triggerSFX,
|
||||||
|
} from '@pascal-app/editor'
|
||||||
|
import {
|
||||||
|
CHIMNEY_PRESET_KEYS,
|
||||||
|
CHIMNEY_PRESET_LABELS,
|
||||||
|
type ChimneyPresetKey,
|
||||||
|
chimneyPresets,
|
||||||
|
detectActiveChimneyPreset,
|
||||||
|
} from './presets'
|
||||||
|
|
||||||
|
// Tiny clsx-equivalent. The editor package doesn't re-export the
|
||||||
|
// legacy `cn` helper; inlining keeps this panel self-contained.
|
||||||
|
const cn = (...classes: Array<string | false | undefined | null>): string =>
|
||||||
|
classes.filter(Boolean).join(' ')
|
||||||
|
|
||||||
|
type ChimneyType = 'cap' | 'flues' | 'shoulder' | 'bands' | 'cricket' | 'panels'
|
||||||
|
|
||||||
|
const CHIMNEY_TYPE_OPTIONS: Array<{ label: string; value: ChimneyType }> = [
|
||||||
|
{ label: 'Cap', value: 'cap' },
|
||||||
|
{ label: 'Flues', value: 'flues' },
|
||||||
|
{ label: 'Shoulder', value: 'shoulder' },
|
||||||
|
{ label: 'Bands', value: 'bands' },
|
||||||
|
{ label: 'Cricket', value: 'cricket' },
|
||||||
|
{ label: 'Panels', value: 'panels' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function ChimneyPanel() {
|
||||||
|
const [chimneyType, setChimneyType] = useState<ChimneyType>('cap')
|
||||||
|
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||||
|
const setSelection = useViewer((s) => s.setSelection)
|
||||||
|
const updateNode = useScene((s) => s.updateNode)
|
||||||
|
const deleteNode = useScene((s) => s.deleteNode)
|
||||||
|
|
||||||
|
const storeNode = useScene((s) =>
|
||||||
|
selectedId ? (s.nodes[selectedId as AnyNode['id']] as ChimneyNode | undefined) : undefined,
|
||||||
|
)
|
||||||
|
// Merge live overrides so slider displays the value the user is actively
|
||||||
|
// dragging, even though the store hasn't been written to yet.
|
||||||
|
const overrides = useLiveNodeOverrides((s) =>
|
||||||
|
selectedId ? (s.get(selectedId as AnyNodeId) as Partial<ChimneyNode> | undefined) : undefined,
|
||||||
|
)
|
||||||
|
const node = storeNode && overrides ? ({ ...storeNode, ...overrides } as ChimneyNode) : storeNode
|
||||||
|
|
||||||
|
const handleUpdate = useCallback(
|
||||||
|
(updates: Partial<ChimneyNode>) => {
|
||||||
|
if (!selectedId) return
|
||||||
|
updateNode(selectedId as AnyNode['id'], updates)
|
||||||
|
},
|
||||||
|
[selectedId, updateNode],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Slider drag → write live override (mesh updates, store untouched).
|
||||||
|
// Slider release → commit to store + clear override.
|
||||||
|
const previewProp = useCallback(
|
||||||
|
(updates: Partial<ChimneyNode>) => {
|
||||||
|
if (!selectedId) return
|
||||||
|
useLiveNodeOverrides.getState().set(selectedId as AnyNodeId, updates)
|
||||||
|
},
|
||||||
|
[selectedId],
|
||||||
|
)
|
||||||
|
const commitProp = useCallback(
|
||||||
|
(updates: Partial<ChimneyNode>) => {
|
||||||
|
if (!selectedId) return
|
||||||
|
updateNode(selectedId as AnyNode['id'], updates)
|
||||||
|
// If reparenting was part of the patch, flag both segments dirty so the
|
||||||
|
// roof system rebuilds them.
|
||||||
|
if (updates.roofSegmentId !== undefined) {
|
||||||
|
const state = useScene.getState()
|
||||||
|
const prev = node?.roofSegmentId
|
||||||
|
if (prev) state.dirtyNodes.add(prev as AnyNodeId)
|
||||||
|
state.dirtyNodes.add(updates.roofSegmentId as AnyNodeId)
|
||||||
|
state.dirtyNodes.add(selectedId as AnyNodeId)
|
||||||
|
}
|
||||||
|
useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId)
|
||||||
|
},
|
||||||
|
[node, selectedId, updateNode],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setSelection({ selectedIds: [] })
|
||||||
|
}, [setSelection])
|
||||||
|
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
if (node?.roofSegmentId) {
|
||||||
|
setSelection({ selectedIds: [node.roofSegmentId as AnyNode['id']] })
|
||||||
|
}
|
||||||
|
}, [node?.roofSegmentId, setSelection])
|
||||||
|
|
||||||
|
const handleDelete = useCallback(() => {
|
||||||
|
if (!(selectedId && node)) return
|
||||||
|
triggerSFX('sfx:item-delete')
|
||||||
|
const segmentId = node.roofSegmentId
|
||||||
|
// Unlist from host segment's children before deleting the node.
|
||||||
|
if (segmentId) {
|
||||||
|
const state = useScene.getState()
|
||||||
|
const segment = state.nodes[segmentId as AnyNodeId] as RoofSegmentNode | undefined
|
||||||
|
if (segment) {
|
||||||
|
state.updateNode(segmentId as AnyNode['id'], {
|
||||||
|
children: (segment.children ?? []).filter((id) => id !== selectedId),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deleteNode(selectedId as AnyNodeId)
|
||||||
|
if (segmentId) {
|
||||||
|
useScene.getState().dirtyNodes.add(segmentId as AnyNodeId)
|
||||||
|
setSelection({ selectedIds: [segmentId as AnyNode['id']] })
|
||||||
|
} else {
|
||||||
|
setSelection({ selectedIds: [] })
|
||||||
|
}
|
||||||
|
}, [selectedId, node, deleteNode, setSelection])
|
||||||
|
|
||||||
|
if (!(node && node.type === 'chimney' && selectedId)) return null
|
||||||
|
|
||||||
|
const scenestate = useScene.getState()
|
||||||
|
const segment = node.roofSegmentId
|
||||||
|
? (scenestate.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined)
|
||||||
|
: undefined
|
||||||
|
const roof = segment?.parentId
|
||||||
|
? (scenestate.nodes[segment.parentId as AnyNodeId] as RoofNode | undefined)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
// ---- True world-space helpers -------------------------------------------
|
||||||
|
// We use the registered THREE.js Object3D matrices so the conversion picks
|
||||||
|
// up EVERY ancestor transform (building rotation, level, etc.) and not
|
||||||
|
// just roof + segment. Falls back to identity if a matrix isn't available
|
||||||
|
// yet (rare timing edge case).
|
||||||
|
|
||||||
|
const chimneyObj = sceneRegistry.nodes.get(selectedId)
|
||||||
|
if (chimneyObj) chimneyObj.updateWorldMatrix(true, false)
|
||||||
|
|
||||||
|
// World pose of the chimney's group origin (after segment.position +
|
||||||
|
// segment.rotation + roof + building, etc.). Used as the basis for the
|
||||||
|
// chimney's actual world position (which is groupOrigin + chimney.position
|
||||||
|
// rotated through the chain).
|
||||||
|
const computeChimneyWorldPos = () => {
|
||||||
|
if (!chimneyObj) return { x: 0, z: 0 }
|
||||||
|
// The chimney's outer group is at segment.position + segment.rotation
|
||||||
|
// already; chimney.position is applied inside the geometry. To get the
|
||||||
|
// world position of the chimney's center we transform its local center
|
||||||
|
// (chimney.position[0], 0, chimney.position[2]) through the outer group.
|
||||||
|
const localPt = new Vector3(node.position[0] ?? 0, 0, node.position[2] ?? 0)
|
||||||
|
const worldPt = localPt.applyMatrix4(chimneyObj.matrixWorld)
|
||||||
|
return { x: worldPt.x, z: worldPt.z }
|
||||||
|
}
|
||||||
|
const computeChimneyWorldRotation = () => {
|
||||||
|
if (!chimneyObj) return node.rotation ?? 0
|
||||||
|
// Extract Y rotation from the outer group's world matrix. Assumes only
|
||||||
|
// Y-axis ancestor rotations (true for our scene — buildings can rotate
|
||||||
|
// around Y; levels/roofs/segments all rotate around Y).
|
||||||
|
const m = chimneyObj.matrixWorld.elements
|
||||||
|
// 3x3 rotation portion (column-major): m[0]=cos, m[2]=-sin for pure Y rot.
|
||||||
|
const ancestorWorldY = Math.atan2(-(m[2] ?? 0), m[0] ?? 1)
|
||||||
|
return ancestorWorldY + (node.rotation ?? 0)
|
||||||
|
}
|
||||||
|
const { x: worldX_now, z: worldZ_now } = computeChimneyWorldPos()
|
||||||
|
const worldRotation_now = computeChimneyWorldRotation()
|
||||||
|
|
||||||
|
// Find any roof-segment whose footprint contains a given world (x, z).
|
||||||
|
const findSegmentForWorldPoint = (
|
||||||
|
wx: number,
|
||||||
|
wz: number,
|
||||||
|
): { segment: RoofSegmentNode; localX: number; localZ: number } | null => {
|
||||||
|
const state = useScene.getState()
|
||||||
|
const worldPt = new Vector3(wx, 0, wz)
|
||||||
|
for (const candidate of Object.values(state.nodes)) {
|
||||||
|
if (!candidate || candidate.type !== 'roof-segment') continue
|
||||||
|
const seg = candidate as RoofSegmentNode
|
||||||
|
const segObj = sceneRegistry.nodes.get(seg.id)
|
||||||
|
if (!segObj) continue
|
||||||
|
segObj.updateWorldMatrix(true, false)
|
||||||
|
const local = segObj.worldToLocal(worldPt.clone())
|
||||||
|
if (Math.abs(local.x) <= seg.width / 2 && Math.abs(local.z) <= seg.depth / 2) {
|
||||||
|
return { segment: seg, localX: local.x, localZ: local.z }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// World→segment-local for a given segment. Uses the segment's registered
|
||||||
|
// mesh (whose world matrix already walks every ancestor transform).
|
||||||
|
const worldToSegLocal = (
|
||||||
|
wx: number,
|
||||||
|
wz: number,
|
||||||
|
seg: RoofSegmentNode,
|
||||||
|
): { localX: number; localZ: number } => {
|
||||||
|
const segObj = sceneRegistry.nodes.get(seg.id)
|
||||||
|
if (!segObj) return { localX: wx, localZ: wz }
|
||||||
|
segObj.updateWorldMatrix(true, false)
|
||||||
|
const local = segObj.worldToLocal(new Vector3(wx, 0, wz))
|
||||||
|
return { localX: local.x, localZ: local.z }
|
||||||
|
}
|
||||||
|
|
||||||
|
// World-space slider range = bounding box of the chimney's parent roof
|
||||||
|
// segments. Computed from each segment mesh's world matrix so the range
|
||||||
|
// is in TRUE world coords too.
|
||||||
|
let worldMinX = worldX_now - 20
|
||||||
|
let worldMaxX = worldX_now + 20
|
||||||
|
let worldMinZ = worldZ_now - 20
|
||||||
|
let worldMaxZ = worldZ_now + 20
|
||||||
|
if (roof) {
|
||||||
|
let lo_x = Number.POSITIVE_INFINITY
|
||||||
|
let hi_x = Number.NEGATIVE_INFINITY
|
||||||
|
let lo_z = Number.POSITIVE_INFINITY
|
||||||
|
let hi_z = Number.NEGATIVE_INFINITY
|
||||||
|
for (const childId of roof.children ?? []) {
|
||||||
|
const seg = scenestate.nodes[childId as AnyNodeId] as RoofSegmentNode | undefined
|
||||||
|
if (!seg) continue
|
||||||
|
const segObj = sceneRegistry.nodes.get(seg.id)
|
||||||
|
if (!segObj) continue
|
||||||
|
segObj.updateWorldMatrix(true, false)
|
||||||
|
const segWorldCenter = new Vector3().applyMatrix4(segObj.matrixWorld)
|
||||||
|
// Use a circular bound (sqrt(w^2 + d^2)/2) — rotation-agnostic.
|
||||||
|
const r = Math.hypot(seg.width, seg.depth) / 2
|
||||||
|
lo_x = Math.min(lo_x, segWorldCenter.x - r)
|
||||||
|
hi_x = Math.max(hi_x, segWorldCenter.x + r)
|
||||||
|
lo_z = Math.min(lo_z, segWorldCenter.z - r)
|
||||||
|
hi_z = Math.max(hi_z, segWorldCenter.z + r)
|
||||||
|
}
|
||||||
|
if (Number.isFinite(lo_x)) {
|
||||||
|
worldMinX = lo_x
|
||||||
|
worldMaxX = hi_x
|
||||||
|
worldMinZ = lo_z
|
||||||
|
worldMaxZ = hi_z
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit a new world (x, z). Finds whichever segment contains the point;
|
||||||
|
// if it differs from the current segment, reparents while preserving the
|
||||||
|
// chimney's world rotation and the world Y of the chimney top (via
|
||||||
|
// heightAboveRidge).
|
||||||
|
const commitWorldPosition = (newWorldX: number, newWorldZ: number) => {
|
||||||
|
if (!segment) return
|
||||||
|
const oldWorldRotation = worldRotation_now
|
||||||
|
const oldHeightAboveRidge = node.heightAboveRidge ?? 1
|
||||||
|
const oldPeakY = segment.wallHeight + getActiveRoofHeight(segment)
|
||||||
|
// Compute the chimney's current world top Y by transforming the segment-
|
||||||
|
// local top point through the chimney's matrix.
|
||||||
|
let oldWorldTopY = 0
|
||||||
|
if (chimneyObj) {
|
||||||
|
const localTop = new Vector3(
|
||||||
|
node.position[0] ?? 0,
|
||||||
|
oldPeakY + oldHeightAboveRidge,
|
||||||
|
node.position[2] ?? 0,
|
||||||
|
)
|
||||||
|
oldWorldTopY = localTop.applyMatrix4(chimneyObj.matrixWorld).y
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = findSegmentForWorldPoint(newWorldX, newWorldZ)
|
||||||
|
if (target && target.segment.id !== segment.id) {
|
||||||
|
const newSegObj = sceneRegistry.nodes.get(target.segment.id)
|
||||||
|
const newPeakY = target.segment.wallHeight + getActiveRoofHeight(target.segment)
|
||||||
|
|
||||||
|
// World Y of the new chimney's group origin (at target localX,Z, y=0).
|
||||||
|
let newOriginWorldY = 0
|
||||||
|
if (newSegObj) {
|
||||||
|
newSegObj.updateWorldMatrix(true, false)
|
||||||
|
newOriginWorldY = new Vector3(target.localX, 0, target.localZ)
|
||||||
|
.applyMatrix4(newSegObj.matrixWorld).y
|
||||||
|
}
|
||||||
|
const newHeightAboveRidge = Math.max(0.1, oldWorldTopY - newOriginWorldY - newPeakY)
|
||||||
|
|
||||||
|
// Preserve world rotation: extract the new segment's ancestor world
|
||||||
|
// Y-rotation from its matrix, then compute the chimney-local rotation
|
||||||
|
// that yields the same world rotation.
|
||||||
|
let newAncestorWorldY = 0
|
||||||
|
if (newSegObj) {
|
||||||
|
const m = newSegObj.matrixWorld.elements
|
||||||
|
newAncestorWorldY = Math.atan2(-(m[2] ?? 0), m[0] ?? 1)
|
||||||
|
}
|
||||||
|
const newSegLocalRot = oldWorldRotation - newAncestorWorldY
|
||||||
|
|
||||||
|
commitProp({
|
||||||
|
roofSegmentId: target.segment.id,
|
||||||
|
parentId: target.segment.id,
|
||||||
|
position: [target.localX, 0, target.localZ],
|
||||||
|
rotation: newSegLocalRot,
|
||||||
|
heightAboveRidge: newHeightAboveRidge,
|
||||||
|
} as Partial<ChimneyNode>)
|
||||||
|
} else {
|
||||||
|
// Same segment, just convert world → segment-local.
|
||||||
|
const local = worldToSegLocal(newWorldX, newWorldZ, segment)
|
||||||
|
commitProp({ position: [local.localX, 0, local.localZ] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit a new world rotation. Stays parented to the current segment.
|
||||||
|
const commitWorldRotation = (newWorldRot: number) => {
|
||||||
|
if (!segment) return
|
||||||
|
let ancestorWorldY = 0
|
||||||
|
const segObj = sceneRegistry.nodes.get(segment.id)
|
||||||
|
if (segObj) {
|
||||||
|
segObj.updateWorldMatrix(true, false)
|
||||||
|
const m = segObj.matrixWorld.elements
|
||||||
|
ancestorWorldY = Math.atan2(-(m[2] ?? 0), m[0] ?? 1)
|
||||||
|
}
|
||||||
|
commitProp({ rotation: newWorldRot - ancestorWorldY })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match the current store node against the preset table so the
|
||||||
|
// segmented control highlights "the preset you'd land on if you
|
||||||
|
// applied X again". Compare against the store node, not the live-
|
||||||
|
// override-merged `node`, so the highlight is stable across slider
|
||||||
|
// drags. Null means the user has tweaked away from any preset; the
|
||||||
|
// segmented control will then render with no segment selected.
|
||||||
|
const activePreset = useMemo(() => detectActiveChimneyPreset(storeNode), [storeNode])
|
||||||
|
const applyPreset = useCallback(
|
||||||
|
(key: ChimneyPresetKey) => {
|
||||||
|
commitProp(chimneyPresets[key] as Partial<ChimneyNode>)
|
||||||
|
triggerSFX('sfx:item-pick')
|
||||||
|
},
|
||||||
|
[commitProp],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PanelWrapper
|
||||||
|
icon="/icons/roof.png"
|
||||||
|
onBack={node.roofSegmentId ? handleBack : undefined}
|
||||||
|
onClose={handleClose}
|
||||||
|
title={node.name || 'Chimney'}
|
||||||
|
width={300}
|
||||||
|
>
|
||||||
|
<PanelSection title="Style">
|
||||||
|
<SegmentedControl
|
||||||
|
onChange={(v) => applyPreset(v as ChimneyPresetKey)}
|
||||||
|
options={CHIMNEY_PRESET_KEYS.map((k) => ({
|
||||||
|
label: CHIMNEY_PRESET_LABELS[k],
|
||||||
|
value: k,
|
||||||
|
}))}
|
||||||
|
// Empty string when no preset matches — nothing highlighted,
|
||||||
|
// which reads correctly as "custom".
|
||||||
|
value={activePreset ?? ''}
|
||||||
|
/>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection title="Footprint">
|
||||||
|
<SegmentedControl
|
||||||
|
onChange={(v) => handleUpdate({ bodyShape: v })}
|
||||||
|
options={[
|
||||||
|
{ label: 'Square', value: 'square' },
|
||||||
|
{ label: 'Round', value: 'round' },
|
||||||
|
]}
|
||||||
|
value={node.bodyShape ?? 'square'}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label={(node.bodyShape ?? 'square') === 'round' ? 'Diameter' : 'Width'}
|
||||||
|
max={3}
|
||||||
|
min={0.2}
|
||||||
|
onChange={(v) => previewProp({ width: v })}
|
||||||
|
onCommit={(v) => commitProp({ width: v })}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.05}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round(node.width * 100) / 100}
|
||||||
|
/>
|
||||||
|
{(node.bodyShape ?? 'square') !== 'round' && (
|
||||||
|
<SliderControl
|
||||||
|
label="Depth"
|
||||||
|
max={3}
|
||||||
|
min={0.2}
|
||||||
|
onChange={(v) => previewProp({ depth: v })}
|
||||||
|
onCommit={(v) => commitProp({ depth: v })}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.05}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round(node.depth * 100) / 100}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<SliderControl
|
||||||
|
label="Hollow Depth"
|
||||||
|
max={3}
|
||||||
|
min={0}
|
||||||
|
onChange={(v) => previewProp({ bodyHollowDepth: v })}
|
||||||
|
onCommit={(v) => commitProp({ bodyHollowDepth: v })}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.05}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round((node.bodyHollowDepth ?? 0.6) * 100) / 100}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Wall Thickness"
|
||||||
|
max={0.3}
|
||||||
|
min={0}
|
||||||
|
onChange={(v) => previewProp({ bodyHollowMargin: v })}
|
||||||
|
onCommit={(v) => commitProp({ bodyHollowMargin: v })}
|
||||||
|
precision={3}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.005}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round((node.bodyHollowMargin ?? 0.08) * 1000) / 1000}
|
||||||
|
/>
|
||||||
|
{(node.bodyShape ?? 'square') !== 'round' && (
|
||||||
|
<SliderControl
|
||||||
|
label="Corner Bevel"
|
||||||
|
max={Math.max(0, Math.min(node.width, node.depth) / 2 - 0.005)}
|
||||||
|
min={0}
|
||||||
|
onChange={(v) => previewProp({ cornerBevel: v })}
|
||||||
|
onCommit={(v) => commitProp({ cornerBevel: v })}
|
||||||
|
precision={3}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.005}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round((node.cornerBevel ?? 0) * 1000) / 1000}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection title="Height">
|
||||||
|
<SliderControl
|
||||||
|
label="Above Ridge"
|
||||||
|
max={5}
|
||||||
|
min={0.1}
|
||||||
|
onChange={(v) => previewProp({ heightAboveRidge: v })}
|
||||||
|
onCommit={(v) => commitProp({ heightAboveRidge: v })}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.1}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round(node.heightAboveRidge * 100) / 100}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Cutout Offset"
|
||||||
|
max={0.5}
|
||||||
|
min={0}
|
||||||
|
onChange={(v) => previewProp({ cutoutOffset: v })}
|
||||||
|
onCommit={(v) => commitProp({ cutoutOffset: v })}
|
||||||
|
precision={3}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.005}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round((node.cutoutOffset ?? 0) * 1000) / 1000}
|
||||||
|
/>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection title="Position">
|
||||||
|
<SliderControl
|
||||||
|
label="X"
|
||||||
|
max={Math.round(worldMaxX * 10) / 10}
|
||||||
|
min={Math.round(worldMinX * 10) / 10}
|
||||||
|
onChange={(newWorldX) => {
|
||||||
|
// Live preview: keep the chimney parented to its current segment
|
||||||
|
// and update its segment-local position so the visual matches the
|
||||||
|
// dragged world X. Reparenting (if any) happens on commit.
|
||||||
|
if (!segment) return
|
||||||
|
const local = worldToSegLocal(newWorldX, worldZ_now, segment)
|
||||||
|
previewProp({ position: [local.localX, 0, local.localZ] })
|
||||||
|
}}
|
||||||
|
onCommit={(newWorldX) => commitWorldPosition(newWorldX, worldZ_now)}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.05}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round(worldX_now * 100) / 100}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Z"
|
||||||
|
max={Math.round(worldMaxZ * 10) / 10}
|
||||||
|
min={Math.round(worldMinZ * 10) / 10}
|
||||||
|
onChange={(newWorldZ) => {
|
||||||
|
if (!segment) return
|
||||||
|
const local = worldToSegLocal(worldX_now, newWorldZ, segment)
|
||||||
|
previewProp({ position: [local.localX, 0, local.localZ] })
|
||||||
|
}}
|
||||||
|
onCommit={(newWorldZ) => commitWorldPosition(worldX_now, newWorldZ)}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.05}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round(worldZ_now * 100) / 100}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Rotation"
|
||||||
|
max={180}
|
||||||
|
min={-180}
|
||||||
|
onChange={(degrees) => {
|
||||||
|
// World rotation → segment-local rotation for the current segment.
|
||||||
|
const newWorldRot = (degrees * Math.PI) / 180
|
||||||
|
let ancestorWorldY = 0
|
||||||
|
if (segment) {
|
||||||
|
const segObj = sceneRegistry.nodes.get(segment.id)
|
||||||
|
if (segObj) {
|
||||||
|
segObj.updateWorldMatrix(true, false)
|
||||||
|
const m = segObj.matrixWorld.elements
|
||||||
|
ancestorWorldY = Math.atan2(-(m[2] ?? 0), m[0] ?? 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
previewProp({ rotation: newWorldRot - ancestorWorldY })
|
||||||
|
}}
|
||||||
|
onCommit={(degrees) => commitWorldRotation((degrees * Math.PI) / 180)}
|
||||||
|
precision={0}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={1}
|
||||||
|
unit="°"
|
||||||
|
value={Math.round((worldRotation_now * 180) / Math.PI)}
|
||||||
|
/>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection title="Chimney Type">
|
||||||
|
<div className="grid grid-cols-2 gap-1.5 px-1 pt-1">
|
||||||
|
{CHIMNEY_TYPE_OPTIONS.filter((option) => {
|
||||||
|
// Cricket and Panels both rely on a flat face — hide them for
|
||||||
|
// round bodies.
|
||||||
|
if ((node.bodyShape ?? 'square') === 'round') {
|
||||||
|
return option.value !== 'cricket' && option.value !== 'panels'
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}).map((option) => {
|
||||||
|
const isSelected = chimneyType === option.value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'flex min-h-12 items-center rounded-lg border px-3 py-2.5 text-left text-xs transition-colors',
|
||||||
|
isSelected
|
||||||
|
? 'border-orange-400/60 bg-orange-400/10 text-foreground'
|
||||||
|
: 'border-border/50 bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground',
|
||||||
|
)}
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => setChimneyType(option.value)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="truncate font-medium">{option.label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{chimneyType === 'cap' && (
|
||||||
|
<>
|
||||||
|
<SegmentedControl
|
||||||
|
className="mt-2"
|
||||||
|
onChange={(v) => handleUpdate({ cap: v !== 'none', capShape: v })}
|
||||||
|
options={[
|
||||||
|
{ label: 'None', value: 'none' },
|
||||||
|
{ label: 'Sloped', value: 'sloped' },
|
||||||
|
{ label: 'Flat', value: 'flat' },
|
||||||
|
{ label: 'Stepped', value: 'stepped' },
|
||||||
|
]}
|
||||||
|
value={node.capShape ?? 'sloped'}
|
||||||
|
/>
|
||||||
|
{(node.capShape ?? 'sloped') !== 'none' && (
|
||||||
|
<>
|
||||||
|
<SliderControl
|
||||||
|
label="Overhang"
|
||||||
|
max={0.2}
|
||||||
|
min={0}
|
||||||
|
onChange={(v) => previewProp({ capOverhang: v })}
|
||||||
|
onCommit={(v) => commitProp({ capOverhang: v })}
|
||||||
|
precision={3}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.005}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round((node.capOverhang ?? 0.04) * 1000) / 1000}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Thickness"
|
||||||
|
max={0.3}
|
||||||
|
min={0.02}
|
||||||
|
onChange={(v) => previewProp({ capThickness: v })}
|
||||||
|
onCommit={(v) => commitProp({ capThickness: v })}
|
||||||
|
precision={3}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.005}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round((node.capThickness ?? 0.08) * 1000) / 1000}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{chimneyType === 'shoulder' && (
|
||||||
|
<>
|
||||||
|
<SegmentedControl
|
||||||
|
className="mt-2"
|
||||||
|
onChange={(v) => handleUpdate({ shoulderStyle: v })}
|
||||||
|
options={[
|
||||||
|
{ label: 'None', value: 'none' },
|
||||||
|
{ label: 'Tapered', value: 'tapered' },
|
||||||
|
{ label: 'Corbeled', value: 'corbeled' },
|
||||||
|
]}
|
||||||
|
value={node.shoulderStyle ?? 'none'}
|
||||||
|
/>
|
||||||
|
{(node.shoulderStyle ?? 'none') !== 'none' && (
|
||||||
|
<>
|
||||||
|
<SliderControl
|
||||||
|
label="Height"
|
||||||
|
max={3}
|
||||||
|
min={0.1}
|
||||||
|
onChange={(v) => previewProp({ shoulderHeight: v })}
|
||||||
|
onCommit={(v) => commitProp({ shoulderHeight: v })}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.05}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round((node.shoulderHeight ?? 0.5) * 100) / 100}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Extent"
|
||||||
|
max={0.5}
|
||||||
|
min={0}
|
||||||
|
onChange={(v) => previewProp({ shoulderExtent: v })}
|
||||||
|
onCommit={(v) => commitProp({ shoulderExtent: v })}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.01}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round((node.shoulderExtent ?? 0.1) * 100) / 100}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{chimneyType === 'flues' && (
|
||||||
|
<>
|
||||||
|
<SliderControl
|
||||||
|
label="Count"
|
||||||
|
max={4}
|
||||||
|
min={0}
|
||||||
|
onChange={(v) => previewProp({ flueCount: Math.round(v) })}
|
||||||
|
onCommit={(v) => commitProp({ flueCount: Math.round(v) })}
|
||||||
|
precision={0}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={1}
|
||||||
|
unit=""
|
||||||
|
value={node.flueCount ?? 1}
|
||||||
|
/>
|
||||||
|
{(node.flueCount ?? 1) > 0 && (
|
||||||
|
<>
|
||||||
|
<SegmentedControl
|
||||||
|
onChange={(v) => handleUpdate({ flueShape: v })}
|
||||||
|
options={[
|
||||||
|
{ label: 'Round', value: 'round' },
|
||||||
|
{ label: 'Square', value: 'square' },
|
||||||
|
]}
|
||||||
|
value={node.flueShape ?? 'round'}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Diameter"
|
||||||
|
max={Math.max(0.4, node.width)}
|
||||||
|
min={0.05}
|
||||||
|
onChange={(v) => previewProp({ flueDiameter: v })}
|
||||||
|
onCommit={(v) => commitProp({ flueDiameter: v })}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.01}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round((node.flueDiameter ?? 0.22) * 100) / 100}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Height"
|
||||||
|
max={1.5}
|
||||||
|
min={0.05}
|
||||||
|
onChange={(v) => previewProp({ flueHeight: v })}
|
||||||
|
onCommit={(v) => commitProp({ flueHeight: v })}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.01}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round((node.flueHeight ?? 0.3) * 100) / 100}
|
||||||
|
/>
|
||||||
|
{(node.flueCount ?? 1) > 1 && (
|
||||||
|
<SliderControl
|
||||||
|
label="Spacing"
|
||||||
|
max={1}
|
||||||
|
min={0}
|
||||||
|
onChange={(v) => previewProp({ flueSpacing: v })}
|
||||||
|
onCommit={(v) => commitProp({ flueSpacing: v })}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.05}
|
||||||
|
value={Math.round((node.flueSpacing ?? 1) * 100) / 100}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<SliderControl
|
||||||
|
label="Wall Thickness"
|
||||||
|
max={Math.max(0.1, (node.flueDiameter ?? 0.22) / 2 - 0.01)}
|
||||||
|
min={0}
|
||||||
|
onChange={(v) => previewProp({ flueWallThickness: v })}
|
||||||
|
onCommit={(v) => commitProp({ flueWallThickness: v })}
|
||||||
|
precision={3}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.005}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round((node.flueWallThickness ?? 0.02) * 1000) / 1000}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{chimneyType === 'bands' && (
|
||||||
|
<>
|
||||||
|
<SegmentedControl
|
||||||
|
className="mt-2"
|
||||||
|
onChange={(v) => handleUpdate({ bandStyle: v })}
|
||||||
|
options={[
|
||||||
|
{ label: 'None', value: 'none' },
|
||||||
|
{ label: 'Single', value: 'single' },
|
||||||
|
{ label: 'Double', value: 'double' },
|
||||||
|
]}
|
||||||
|
value={node.bandStyle ?? 'none'}
|
||||||
|
/>
|
||||||
|
{(node.bandStyle ?? 'none') !== 'none' && (
|
||||||
|
<>
|
||||||
|
<SliderControl
|
||||||
|
label="Thickness"
|
||||||
|
max={0.4}
|
||||||
|
min={0.02}
|
||||||
|
onChange={(v) => previewProp({ bandHeight: v })}
|
||||||
|
onCommit={(v) => commitProp({ bandHeight: v })}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.01}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round((node.bandHeight ?? 0.1) * 100) / 100}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Extent"
|
||||||
|
max={0.2}
|
||||||
|
min={0}
|
||||||
|
onChange={(v) => previewProp({ bandExtent: v })}
|
||||||
|
onCommit={(v) => commitProp({ bandExtent: v })}
|
||||||
|
precision={3}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.005}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round((node.bandExtent ?? 0.04) * 1000) / 1000}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Offset"
|
||||||
|
max={3}
|
||||||
|
min={0}
|
||||||
|
onChange={(v) => previewProp({ bandOffset: v })}
|
||||||
|
onCommit={(v) => commitProp({ bandOffset: v })}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.05}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round((node.bandOffset ?? 0.4) * 100) / 100}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{chimneyType === 'cricket' && (
|
||||||
|
<>
|
||||||
|
<SegmentedControl
|
||||||
|
className="mt-2"
|
||||||
|
onChange={(v) => handleUpdate({ cricketStyle: v })}
|
||||||
|
options={[
|
||||||
|
{ label: 'None', value: 'none' },
|
||||||
|
{ label: 'Simple', value: 'simple' },
|
||||||
|
]}
|
||||||
|
value={node.cricketStyle ?? 'none'}
|
||||||
|
/>
|
||||||
|
{(node.cricketStyle ?? 'none') !== 'none' && (
|
||||||
|
<>
|
||||||
|
<SegmentedControl
|
||||||
|
className="mt-2"
|
||||||
|
onChange={(v) => handleUpdate({ cricketSide: v })}
|
||||||
|
options={[
|
||||||
|
{ label: 'Front', value: 'front' },
|
||||||
|
{ label: 'Back', value: 'back' },
|
||||||
|
]}
|
||||||
|
value={node.cricketSide ?? 'front'}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Length"
|
||||||
|
max={2}
|
||||||
|
min={0.1}
|
||||||
|
onChange={(v) => previewProp({ cricketLength: v })}
|
||||||
|
onCommit={(v) => commitProp({ cricketLength: v })}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.05}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round((node.cricketLength ?? 0.6) * 100) / 100}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Height"
|
||||||
|
max={1.5}
|
||||||
|
min={0.05}
|
||||||
|
onChange={(v) => previewProp({ cricketHeight: v })}
|
||||||
|
onCommit={(v) => commitProp({ cricketHeight: v })}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.05}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round((node.cricketHeight ?? 0.4) * 100) / 100}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{chimneyType === 'panels' && (
|
||||||
|
<>
|
||||||
|
<SegmentedControl
|
||||||
|
className="mt-2"
|
||||||
|
onChange={(v) => handleUpdate({ panelStyle: v })}
|
||||||
|
options={[
|
||||||
|
{ label: 'None', value: 'none' },
|
||||||
|
{ label: 'Rectangular', value: 'rectangular' },
|
||||||
|
]}
|
||||||
|
value={node.panelStyle ?? 'none'}
|
||||||
|
/>
|
||||||
|
{(node.panelStyle ?? 'none') !== 'none' && (
|
||||||
|
<>
|
||||||
|
<SliderControl
|
||||||
|
label="Depth"
|
||||||
|
max={0.15}
|
||||||
|
min={0.005}
|
||||||
|
onChange={(v) => previewProp({ panelDepth: v })}
|
||||||
|
onCommit={(v) => commitProp({ panelDepth: v })}
|
||||||
|
precision={3}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.005}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round((node.panelDepth ?? 0.03) * 1000) / 1000}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Height"
|
||||||
|
max={3}
|
||||||
|
min={0.1}
|
||||||
|
onChange={(v) => previewProp({ panelHeight: v })}
|
||||||
|
onCommit={(v) => commitProp({ panelHeight: v })}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.05}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round((node.panelHeight ?? 0.8) * 100) / 100}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Top Offset"
|
||||||
|
max={2}
|
||||||
|
min={0}
|
||||||
|
onChange={(v) => previewProp({ panelOffsetTop: v })}
|
||||||
|
onCommit={(v) => commitProp({ panelOffsetTop: v })}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.05}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round((node.panelOffsetTop ?? 0.15) * 100) / 100}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Side Margin"
|
||||||
|
max={Math.max(0.5, node.width / 2 - 0.05)}
|
||||||
|
min={0.02}
|
||||||
|
onChange={(v) => previewProp({ panelMargin: v })}
|
||||||
|
onCommit={(v) => commitProp({ panelMargin: v })}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.01}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round((node.panelMargin ?? 0.1) * 100) / 100}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection title="Actions">
|
||||||
|
<ActionGroup>
|
||||||
|
<ActionButton
|
||||||
|
className="hover:bg-red-500/20"
|
||||||
|
icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
|
||||||
|
label="Delete"
|
||||||
|
onClick={handleDelete}
|
||||||
|
/>
|
||||||
|
</ActionGroup>
|
||||||
|
</PanelSection>
|
||||||
|
</PanelWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import type { ParametricDescriptor } from '@pascal-app/core'
|
||||||
|
import type { ChimneyNode } from './schema'
|
||||||
|
|
||||||
|
export const chimneyParametrics: ParametricDescriptor<ChimneyNode> = {
|
||||||
|
// The chimney panel is a bespoke tabbed UI (Cap / Flues / Shoulder /
|
||||||
|
// Bands / Cricket / Panels) ported from the archive — auto-derived
|
||||||
|
// groups can't reproduce its layout. `groups` stays declared for the
|
||||||
|
// MCP path and for any future fallback consumer, but the inspector
|
||||||
|
// mounts the custom panel.
|
||||||
|
customPanel: () => import('./panel'),
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
label: 'Body',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'bodyShape',
|
||||||
|
kind: 'enum',
|
||||||
|
options: ['square', 'round'],
|
||||||
|
display: 'segmented',
|
||||||
|
},
|
||||||
|
{ key: 'width', kind: 'number', unit: 'm', min: 0.2, max: 2, step: 0.05 },
|
||||||
|
{
|
||||||
|
key: 'depth',
|
||||||
|
kind: 'number',
|
||||||
|
unit: 'm',
|
||||||
|
min: 0.2,
|
||||||
|
max: 2,
|
||||||
|
step: 0.05,
|
||||||
|
visibleIf: (n) => n.bodyShape === 'square',
|
||||||
|
},
|
||||||
|
{ key: 'heightAboveRidge', kind: 'number', unit: 'm', min: 0.2, max: 3, step: 0.05 },
|
||||||
|
{
|
||||||
|
key: 'cornerBevel',
|
||||||
|
kind: 'number',
|
||||||
|
unit: 'm',
|
||||||
|
min: 0,
|
||||||
|
max: 0.1,
|
||||||
|
step: 0.005,
|
||||||
|
visibleIf: (n) => n.bodyShape === 'square',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Shoulder',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'shoulderStyle',
|
||||||
|
kind: 'enum',
|
||||||
|
options: ['none', 'tapered', 'corbeled'],
|
||||||
|
display: 'segmented',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'shoulderHeight',
|
||||||
|
kind: 'number',
|
||||||
|
unit: 'm',
|
||||||
|
min: 0.1,
|
||||||
|
max: 1.5,
|
||||||
|
step: 0.05,
|
||||||
|
visibleIf: (n) => n.shoulderStyle !== 'none',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'shoulderExtent',
|
||||||
|
kind: 'number',
|
||||||
|
unit: 'm',
|
||||||
|
min: 0,
|
||||||
|
max: 0.5,
|
||||||
|
step: 0.01,
|
||||||
|
visibleIf: (n) => n.shoulderStyle !== 'none',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Cap',
|
||||||
|
fields: [
|
||||||
|
{ key: 'cap', kind: 'boolean' },
|
||||||
|
{
|
||||||
|
key: 'capShape',
|
||||||
|
kind: 'enum',
|
||||||
|
options: ['none', 'sloped', 'flat', 'stepped'],
|
||||||
|
display: 'segmented',
|
||||||
|
visibleIf: (n) => n.cap === true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'capOverhang',
|
||||||
|
kind: 'number',
|
||||||
|
unit: 'm',
|
||||||
|
min: 0,
|
||||||
|
max: 0.2,
|
||||||
|
step: 0.01,
|
||||||
|
visibleIf: (n) => n.cap === true && n.capShape !== 'none',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'capThickness',
|
||||||
|
kind: 'number',
|
||||||
|
unit: 'm',
|
||||||
|
min: 0.02,
|
||||||
|
max: 0.2,
|
||||||
|
step: 0.005,
|
||||||
|
visibleIf: (n) => n.cap === true && n.capShape !== 'none',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Flues',
|
||||||
|
fields: [
|
||||||
|
{ key: 'flueCount', kind: 'number', min: 0, max: 4, step: 1 },
|
||||||
|
{
|
||||||
|
key: 'flueShape',
|
||||||
|
kind: 'enum',
|
||||||
|
options: ['round', 'square'],
|
||||||
|
display: 'segmented',
|
||||||
|
visibleIf: (n) => n.flueCount > 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'flueHeight',
|
||||||
|
kind: 'number',
|
||||||
|
unit: 'm',
|
||||||
|
min: 0.05,
|
||||||
|
max: 0.8,
|
||||||
|
step: 0.01,
|
||||||
|
visibleIf: (n) => n.flueCount > 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'flueDiameter',
|
||||||
|
kind: 'number',
|
||||||
|
unit: 'm',
|
||||||
|
min: 0.05,
|
||||||
|
max: 0.4,
|
||||||
|
step: 0.01,
|
||||||
|
visibleIf: (n) => n.flueCount > 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'flueSpacing',
|
||||||
|
kind: 'number',
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
step: 0.05,
|
||||||
|
visibleIf: (n) => n.flueCount > 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Cricket',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'cricketStyle',
|
||||||
|
kind: 'enum',
|
||||||
|
options: ['none', 'simple'],
|
||||||
|
display: 'segmented',
|
||||||
|
visibleIf: (n) => n.bodyShape === 'square',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cricketSide',
|
||||||
|
kind: 'enum',
|
||||||
|
options: ['front', 'back'],
|
||||||
|
display: 'segmented',
|
||||||
|
visibleIf: (n) => n.bodyShape === 'square' && n.cricketStyle !== 'none',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cricketLength',
|
||||||
|
kind: 'number',
|
||||||
|
unit: 'm',
|
||||||
|
min: 0.2,
|
||||||
|
max: 2,
|
||||||
|
step: 0.05,
|
||||||
|
visibleIf: (n) => n.bodyShape === 'square' && n.cricketStyle !== 'none',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cricketHeight',
|
||||||
|
kind: 'number',
|
||||||
|
unit: 'm',
|
||||||
|
min: 0.1,
|
||||||
|
max: 1,
|
||||||
|
step: 0.05,
|
||||||
|
visibleIf: (n) => n.bodyShape === 'square' && n.cricketStyle !== 'none',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import type { ChimneyNode } from './schema'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Style presets — opinionated starting points the user can pick from
|
||||||
|
* the panel header. Each preset sets shape / silhouette / accessory
|
||||||
|
* fields only; dimensions (`width` / `depth` / `heightAboveRidge`),
|
||||||
|
* placement (`position` / `rotation` / `roofSegmentId`), and paint
|
||||||
|
* (`material*` / `topMaterial*`) are left untouched so applying a
|
||||||
|
* preset to an already-sized chimney resizes nothing and doesn't
|
||||||
|
* overwrite the user's paint choices.
|
||||||
|
*
|
||||||
|
* Presets are intentionally distinct silhouettes:
|
||||||
|
* - `brick` — straight body, double band, flat overhanging cap
|
||||||
|
* - `modern` — minimal flat cap, recessed decorative panels
|
||||||
|
* - `round` — cylindrical body, single band, industrial look
|
||||||
|
*/
|
||||||
|
export type ChimneyPresetKey = 'brick' | 'modern' | 'round'
|
||||||
|
|
||||||
|
export const CHIMNEY_PRESET_KEYS: ChimneyPresetKey[] = ['brick', 'modern', 'round']
|
||||||
|
|
||||||
|
export const CHIMNEY_PRESET_LABELS: Record<ChimneyPresetKey, string> = {
|
||||||
|
brick: 'Brick',
|
||||||
|
modern: 'Modern',
|
||||||
|
round: 'Round',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const chimneyPresets: Record<ChimneyPresetKey, Partial<ChimneyNode>> = {
|
||||||
|
brick: {
|
||||||
|
bodyShape: 'square',
|
||||||
|
shoulderStyle: 'none',
|
||||||
|
cap: true,
|
||||||
|
capShape: 'flat',
|
||||||
|
capOverhang: 0.04,
|
||||||
|
capThickness: 0.06,
|
||||||
|
bandStyle: 'double',
|
||||||
|
bandHeight: 0.05,
|
||||||
|
bandExtent: 0.025,
|
||||||
|
bandOffset: 0.4,
|
||||||
|
cricketStyle: 'none',
|
||||||
|
cornerBevel: 0,
|
||||||
|
panelStyle: 'none',
|
||||||
|
flueCount: 1,
|
||||||
|
flueShape: 'round',
|
||||||
|
flueDiameter: 0.2,
|
||||||
|
flueHeight: 0.25,
|
||||||
|
flueSpacing: 1,
|
||||||
|
},
|
||||||
|
modern: {
|
||||||
|
bodyShape: 'square',
|
||||||
|
shoulderStyle: 'none',
|
||||||
|
cap: true,
|
||||||
|
capShape: 'flat',
|
||||||
|
capOverhang: 0.02,
|
||||||
|
capThickness: 0.04,
|
||||||
|
bandStyle: 'none',
|
||||||
|
cricketStyle: 'none',
|
||||||
|
cornerBevel: 0,
|
||||||
|
panelStyle: 'rectangular',
|
||||||
|
panelDepth: 0.015,
|
||||||
|
panelHeight: 1.0,
|
||||||
|
panelOffsetTop: 0.2,
|
||||||
|
panelMargin: 0.12,
|
||||||
|
flueCount: 1,
|
||||||
|
flueShape: 'round',
|
||||||
|
flueDiameter: 0.16,
|
||||||
|
flueHeight: 0.18,
|
||||||
|
flueSpacing: 1,
|
||||||
|
},
|
||||||
|
round: {
|
||||||
|
bodyShape: 'round',
|
||||||
|
shoulderStyle: 'none',
|
||||||
|
cap: true,
|
||||||
|
capShape: 'flat',
|
||||||
|
capOverhang: 0.05,
|
||||||
|
capThickness: 0.05,
|
||||||
|
bandStyle: 'single',
|
||||||
|
bandHeight: 0.04,
|
||||||
|
bandExtent: 0.02,
|
||||||
|
bandOffset: 0.4,
|
||||||
|
cricketStyle: 'none',
|
||||||
|
cornerBevel: 0,
|
||||||
|
panelStyle: 'none',
|
||||||
|
flueCount: 1,
|
||||||
|
flueShape: 'round',
|
||||||
|
flueDiameter: 0.16,
|
||||||
|
flueHeight: 0.2,
|
||||||
|
flueSpacing: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the preset key whose every field matches the supplied node,
|
||||||
|
* or `null` if no preset is an exact match (i.e. the user has tweaked
|
||||||
|
* fields after applying a preset). Used by the panel to highlight the
|
||||||
|
* current preset in the segmented control.
|
||||||
|
*/
|
||||||
|
export function detectActiveChimneyPreset(
|
||||||
|
node: Partial<ChimneyNode> | undefined | null,
|
||||||
|
): ChimneyPresetKey | null {
|
||||||
|
if (!node) return null
|
||||||
|
for (const key of CHIMNEY_PRESET_KEYS) {
|
||||||
|
const preset = chimneyPresets[key] as Record<string, unknown>
|
||||||
|
const n = node as Record<string, unknown>
|
||||||
|
let matches = true
|
||||||
|
for (const k of Object.keys(preset)) {
|
||||||
|
if (n[k] !== preset[k]) {
|
||||||
|
matches = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matches) return key
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { ChimneyNode, RoofSegmentNode } from '@pascal-app/core'
|
||||||
|
import { useEffect, useMemo } from 'react'
|
||||||
|
import * as THREE from 'three'
|
||||||
|
import { buildChimneyGeometry } from './geometry'
|
||||||
|
|
||||||
|
const ghostMaterial = new THREE.MeshStandardMaterial({
|
||||||
|
color: 0xff_ff_ff,
|
||||||
|
emissive: 0xff_ff_ff,
|
||||||
|
emissiveIntensity: 0.12,
|
||||||
|
roughness: 0.85,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.55,
|
||||||
|
depthWrite: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The preview needs a segment fixture to build the body height. The
|
||||||
|
* placement tool passes the segment under the cursor; before any
|
||||||
|
* 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,
|
||||||
|
])
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
geo.body.dispose()
|
||||||
|
geo.cap?.dispose()
|
||||||
|
geo.flues?.dispose()
|
||||||
|
geo.cricket?.dispose()
|
||||||
|
},
|
||||||
|
[geo],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
<mesh
|
||||||
|
geometry={geo.body}
|
||||||
|
material={ghostMaterial}
|
||||||
|
raycast={() => {
|
||||||
|
/* preview should not intercept the cursor */
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{geo.cap && <mesh geometry={geo.cap} material={ghostMaterial} raycast={() => {}} />}
|
||||||
|
{geo.flues && <mesh geometry={geo.flues} material={ghostMaterial} raycast={() => {}} />}
|
||||||
|
{geo.cricket && <mesh geometry={geo.cricket} material={ghostMaterial} raycast={() => {}} />}
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChimneyPreview
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import {
|
||||||
|
type AnyNodeId,
|
||||||
|
type ChimneyNode,
|
||||||
|
type RoofSegmentNode,
|
||||||
|
useLiveNodeOverrides,
|
||||||
|
useRegistry,
|
||||||
|
useScene,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import {
|
||||||
|
createMaterial,
|
||||||
|
createMaterialFromPresetRef,
|
||||||
|
getRoofSegmentBrushes,
|
||||||
|
useNodeEvents,
|
||||||
|
} from '@pascal-app/viewer'
|
||||||
|
import { useEffect, useMemo, useRef } from 'react'
|
||||||
|
import * as THREE from 'three'
|
||||||
|
import { buildChimneyGeometry } from './geometry'
|
||||||
|
import { carveChimneyHoles } from './holes'
|
||||||
|
import { trimChimneyBodyAgainstRoof } from './roof-trim'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chimney renderer. Reads the parent roof-segment so the body height
|
||||||
|
* is derived from `segment.wallHeight + roofHeight + node.heightAboveRidge`.
|
||||||
|
*
|
||||||
|
* **Option C scope**: chimney is rendered as solid geometry that
|
||||||
|
* intersects the roof at the deck line. The decorative CSG-driven
|
||||||
|
* features (cap flue holes, body cavity, panels, bands) are not
|
||||||
|
* rendered in this port — they remain as no-op fields in the schema
|
||||||
|
* until the roof-segment Stage B migration introduces a `roofCutout`
|
||||||
|
* capability the parent can read.
|
||||||
|
*/
|
||||||
|
const ChimneyRenderer = ({ node: storeNode }: { node: ChimneyNode }) => {
|
||||||
|
const ref = useRef<THREE.Group>(null!)
|
||||||
|
useRegistry(storeNode.id, 'chimney', ref)
|
||||||
|
const handlers = useNodeEvents(storeNode, 'chimney')
|
||||||
|
|
||||||
|
// 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 node = useMemo<ChimneyNode>(
|
||||||
|
() => (overrides ? { ...storeNode, ...overrides } : storeNode),
|
||||||
|
[storeNode, overrides],
|
||||||
|
)
|
||||||
|
|
||||||
|
const segment = useScene((state) =>
|
||||||
|
node.roofSegmentId
|
||||||
|
? (state.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined)
|
||||||
|
: undefined,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Geometry + carved CSG depend on the chimney's full schema and the
|
||||||
|
// host segment's shape. Both come in as memoised references — `node`
|
||||||
|
// only re-references when the store node or a live-override actually
|
||||||
|
// changes, `segment` only when the segment's own data changes — so a
|
||||||
|
// two-entry dep array is equivalent to enumerating every field, and
|
||||||
|
// adding a new schema field doesn't risk stale geometry from a
|
||||||
|
// forgotten dep.
|
||||||
|
const geo = useMemo(() => {
|
||||||
|
if (!segment) return null
|
||||||
|
const raw = buildChimneyGeometry(node, segment)
|
||||||
|
// Carve the smoke shaft (body cavity), cap holes, and hollow flue
|
||||||
|
// bores. Matches the v1 roof-system visual.
|
||||||
|
const carved = carveChimneyHoles(raw.body, raw.cap, raw.flues, node, segment)
|
||||||
|
return { ...raw, body: carved.body, cap: carved.cap, flues: carved.flues }
|
||||||
|
}, [node, segment])
|
||||||
|
|
||||||
|
// Segment brushes for the body trim. Building these is non-trivial
|
||||||
|
// (4 CSG-ready Brush instances per segment), so memoise by the shape
|
||||||
|
// fields that drive their geometry. A chimney slider drag changes
|
||||||
|
// `node.*` but not these, so the cached brushes survive the drag —
|
||||||
|
// previously each frame rebuilt all four.
|
||||||
|
const segmentBrushes = useMemo(
|
||||||
|
() => (segment ? getRoofSegmentBrushes(segment) : null),
|
||||||
|
[
|
||||||
|
segment?.roofType,
|
||||||
|
segment?.width,
|
||||||
|
segment?.depth,
|
||||||
|
segment?.wallHeight,
|
||||||
|
segment?.pitch,
|
||||||
|
segment?.wallThickness,
|
||||||
|
segment?.deckThickness,
|
||||||
|
segment?.overhang,
|
||||||
|
segment?.shingleThickness,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (segmentBrushes) {
|
||||||
|
segmentBrushes.deckSlab.geometry.dispose()
|
||||||
|
segmentBrushes.shinSlab.geometry.dispose()
|
||||||
|
segmentBrushes.wallBrush.geometry.dispose()
|
||||||
|
segmentBrushes.innerBrush.geometry.dispose()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[segmentBrushes],
|
||||||
|
)
|
||||||
|
|
||||||
|
// CSG-trim the body against the parent roof segment so the portion
|
||||||
|
// passing through the wall and shingles is hidden. Returns the
|
||||||
|
// original body geometry on any CSG failure (logged via console.error).
|
||||||
|
const trimmedBody = useMemo(() => {
|
||||||
|
if (!geo || !segment || !segmentBrushes) return null
|
||||||
|
return trimChimneyBodyAgainstRoof(geo.body, segment, node, segmentBrushes)
|
||||||
|
}, [geo, segment, node, segmentBrushes])
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (geo) {
|
||||||
|
// The body may have been replaced by the trimmed version —
|
||||||
|
// `trimChimneyBodyAgainstRoof` disposes the original on
|
||||||
|
// success. Dispose `trimmedBody` if present, else the
|
||||||
|
// original body.
|
||||||
|
;(trimmedBody ?? geo.body).dispose()
|
||||||
|
geo.cap?.dispose()
|
||||||
|
geo.flues?.dispose()
|
||||||
|
geo.cricket?.dispose()
|
||||||
|
geo.bands?.dispose()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[geo, trimmedBody],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Per-instance fallback materials. Were previously module-scoped
|
||||||
|
// singletons shared across every chimney — a paint-mode or debug
|
||||||
|
// system that mutates `surfaceMaterial` would have flipped the look
|
||||||
|
// of every unpainted chimney on the scene. Owning them here also
|
||||||
|
// lets us dispose them on unmount.
|
||||||
|
const fallbackBodyMaterial = useMemo(
|
||||||
|
() =>
|
||||||
|
new THREE.MeshStandardMaterial({
|
||||||
|
color: 0xb8_88_72,
|
||||||
|
roughness: 0.85,
|
||||||
|
metalness: 0,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
const fallbackTopMaterial = useMemo(
|
||||||
|
() =>
|
||||||
|
new THREE.MeshStandardMaterial({
|
||||||
|
color: 0xa0_a0_a0,
|
||||||
|
roughness: 0.75,
|
||||||
|
metalness: 0,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
fallbackBodyMaterial.dispose()
|
||||||
|
fallbackTopMaterial.dispose()
|
||||||
|
},
|
||||||
|
[fallbackBodyMaterial, fallbackTopMaterial],
|
||||||
|
)
|
||||||
|
|
||||||
|
const surfaceMaterial = useMemo(() => {
|
||||||
|
if (node.material) return createMaterial(node.material)
|
||||||
|
const preset = createMaterialFromPresetRef(node.materialPreset)
|
||||||
|
return preset ?? fallbackBodyMaterial
|
||||||
|
}, [node.material, node.materialPreset, fallbackBodyMaterial])
|
||||||
|
|
||||||
|
const capSurfaceMaterial = useMemo(() => {
|
||||||
|
if (node.topMaterial) return createMaterial(node.topMaterial)
|
||||||
|
const preset = createMaterialFromPresetRef(node.topMaterialPreset)
|
||||||
|
if (preset) return preset
|
||||||
|
if (node.material) return createMaterial(node.material)
|
||||||
|
const bodyPreset = createMaterialFromPresetRef(node.materialPreset)
|
||||||
|
return bodyPreset ?? fallbackTopMaterial
|
||||||
|
}, [
|
||||||
|
node.topMaterial,
|
||||||
|
node.topMaterialPreset,
|
||||||
|
node.material,
|
||||||
|
node.materialPreset,
|
||||||
|
fallbackTopMaterial,
|
||||||
|
])
|
||||||
|
|
||||||
|
// Two-material array: index 0 = body/surface, index 1 = top. The
|
||||||
|
// geometry buffers are partitioned in `holes.ts:partitionTopFaceGroups`
|
||||||
|
// so the very top face of body/cap/flues lands in group 1 and picks up
|
||||||
|
// the top material — matching the v1 roof-system visual.
|
||||||
|
// Must be declared above the early-return below: hooks can't be
|
||||||
|
// called conditionally without changing the hook-call order between
|
||||||
|
// renders.
|
||||||
|
const surfaceArray = useMemo(
|
||||||
|
() => [surfaceMaterial, capSurfaceMaterial],
|
||||||
|
[surfaceMaterial, capSurfaceMaterial],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!segment || !geo) return null
|
||||||
|
|
||||||
|
// The chimney's geometry bakes its baseY using segment.wallHeight inside
|
||||||
|
// the builder, so the outer group only needs the segment-local X/Z
|
||||||
|
// offset. Y stays at 0 here.
|
||||||
|
|
||||||
|
// Chimneys are mounted inside `RoofRenderer`'s `roof-elements` group,
|
||||||
|
// which sits at the ROOF's origin — not inside the host segment's
|
||||||
|
// transform. Apply the segment's own position/rotation here so a
|
||||||
|
// chimney parented to segment N lands on segment N (and not on the
|
||||||
|
// first segment) once the chimney's segment-local `node.position[0/2]`
|
||||||
|
// is layered in by `geometry.ts`. Mirrors skylight's renderer.
|
||||||
|
return (
|
||||||
|
<group
|
||||||
|
position={segment.position}
|
||||||
|
ref={ref}
|
||||||
|
rotation-y={segment.rotation}
|
||||||
|
visible={node.visible}
|
||||||
|
{...handlers}
|
||||||
|
>
|
||||||
|
<mesh
|
||||||
|
castShadow
|
||||||
|
geometry={trimmedBody ?? geo.body}
|
||||||
|
material={surfaceArray}
|
||||||
|
name="chimney-body"
|
||||||
|
receiveShadow
|
||||||
|
/>
|
||||||
|
{geo.cap && (
|
||||||
|
<mesh
|
||||||
|
castShadow
|
||||||
|
geometry={geo.cap}
|
||||||
|
material={surfaceArray}
|
||||||
|
name="chimney-cap"
|
||||||
|
receiveShadow
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{geo.flues && (
|
||||||
|
<mesh
|
||||||
|
castShadow
|
||||||
|
geometry={geo.flues}
|
||||||
|
material={surfaceArray}
|
||||||
|
name="chimney-flues"
|
||||||
|
receiveShadow
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{geo.cricket && (
|
||||||
|
<mesh
|
||||||
|
castShadow
|
||||||
|
geometry={geo.cricket}
|
||||||
|
material={surfaceMaterial}
|
||||||
|
name="chimney-cricket"
|
||||||
|
receiveShadow
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{geo.bands && (
|
||||||
|
<mesh
|
||||||
|
castShadow
|
||||||
|
geometry={geo.bands}
|
||||||
|
material={surfaceMaterial}
|
||||||
|
name="chimney-bands"
|
||||||
|
receiveShadow
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChimneyRenderer
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { type ChimneyNode, getActiveRoofHeight, type RoofSegmentNode } from '@pascal-app/core'
|
||||||
|
import {
|
||||||
|
Brush,
|
||||||
|
csgEvaluator,
|
||||||
|
csgGeometry,
|
||||||
|
type getRoofSegmentBrushes,
|
||||||
|
prepareBrushForCSG,
|
||||||
|
SUBTRACTION,
|
||||||
|
} from '@pascal-app/viewer'
|
||||||
|
import * as THREE from 'three'
|
||||||
|
import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
||||||
|
import { partitionTopFaceGroups } from './holes'
|
||||||
|
|
||||||
|
const visibleMat = new THREE.MeshBasicMaterial()
|
||||||
|
|
||||||
|
export type SegmentTrimBrushes = NonNullable<ReturnType<typeof getRoofSegmentBrushes>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSG-trim the chimney body against the parent roof segment so the
|
||||||
|
* portion of the chimney that passes through the wall and shingles
|
||||||
|
* is hidden — gives the clean "chimney emerges from the roof" look
|
||||||
|
* the archive shipped. Lives in the chimney folder (not the geometry
|
||||||
|
* builder) because three-bvh-csg + three-mesh-bvh are viewer-only
|
||||||
|
* deps; the renderer is the natural seam since it already imports
|
||||||
|
* from `@pascal-app/viewer`.
|
||||||
|
*
|
||||||
|
* Segment brushes are passed in (built once per segment shape in the
|
||||||
|
* renderer and reused across slider drags); the function does NOT
|
||||||
|
* dispose them. CSG `evaluate` returns a new brush, so the input
|
||||||
|
* brushes survive the call unmutated.
|
||||||
|
*
|
||||||
|
* Returns the input geometry untouched on any CSG failure so the
|
||||||
|
* chimney still renders (just not trimmed).
|
||||||
|
*/
|
||||||
|
export function trimChimneyBodyAgainstRoof(
|
||||||
|
body: THREE.BufferGeometry,
|
||||||
|
segment: RoofSegmentNode,
|
||||||
|
node: ChimneyNode,
|
||||||
|
segBrushes: SegmentTrimBrushes,
|
||||||
|
): THREE.BufferGeometry {
|
||||||
|
const { shinSlab, wallBrush } = segBrushes
|
||||||
|
|
||||||
|
// Wrap the chimney body in a Brush. The body has `node.position` /
|
||||||
|
// `node.rotation` baked into its vertices via `applyNodeTransform`
|
||||||
|
// in `geometry.ts`, so it's already in segment-local space — the
|
||||||
|
// same frame as the roof brushes from `getRoofSegmentBrushes`.
|
||||||
|
const indexed = mergeVertices(body, 1e-4)
|
||||||
|
if (!indexed.getAttribute('normal')) indexed.computeVertexNormals()
|
||||||
|
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 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const chimneyBrush = new Brush(indexed, visibleMat as unknown as THREE.MeshStandardMaterial)
|
||||||
|
chimneyBrush.updateMatrixWorld()
|
||||||
|
prepareBrushForCSG(chimneyBrush)
|
||||||
|
|
||||||
|
let result: THREE.BufferGeometry = body
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Two-pass subtraction: trim the chimney shaft below the eave with
|
||||||
|
// `wallBrush`, then trim the section above the wall but below the
|
||||||
|
// shingles with `shinSlab`. Together these hide the chimney's body
|
||||||
|
// wherever it passes through the roof shell, leaving only the
|
||||||
|
// visible portion above the shingles.
|
||||||
|
const step1 = csgEvaluator.evaluate(chimneyBrush, wallBrush, SUBTRACTION) as Brush
|
||||||
|
prepareBrushForCSG(step1)
|
||||||
|
const step2 = csgEvaluator.evaluate(step1, shinSlab, SUBTRACTION) as Brush
|
||||||
|
|
||||||
|
const out = csgGeometry(step2).clone()
|
||||||
|
const ic = out.getIndex()?.count ?? 0
|
||||||
|
out.clearGroups()
|
||||||
|
if (ic > 0) out.addGroup(0, ic, 0)
|
||||||
|
out.computeVertexNormals()
|
||||||
|
|
||||||
|
// Re-partition the top rim face into group 1 so the body mesh's
|
||||||
|
// `[bodyMaterial, topMaterial]` array routes the rim to the top
|
||||||
|
// material — the CSG step above wiped the partition we set inside
|
||||||
|
// `holes.ts:carveChimneyHoles`. Same threshold as the carve step
|
||||||
|
// (top rim is at `topY`, just below it for safety).
|
||||||
|
const peakY = segment.wallHeight + getActiveRoofHeight(segment)
|
||||||
|
const topY = peakY + node.heightAboveRidge
|
||||||
|
partitionTopFaceGroups(out, topY - 0.05)
|
||||||
|
|
||||||
|
body.dispose()
|
||||||
|
step1.geometry.dispose()
|
||||||
|
step2.geometry.dispose()
|
||||||
|
indexed.dispose()
|
||||||
|
|
||||||
|
result = out
|
||||||
|
} catch (e) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('[chimney] roof-trim CSG failed:', e)
|
||||||
|
indexed.dispose()
|
||||||
|
result = body
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ChimneyNode } from '@pascal-app/core'
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import {
|
||||||
|
type AnyNodeId,
|
||||||
|
ChimneyNode,
|
||||||
|
emitter,
|
||||||
|
type RoofEvent,
|
||||||
|
type RoofNode,
|
||||||
|
type RoofSegmentNode,
|
||||||
|
sceneRegistry,
|
||||||
|
useScene,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
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 { resolveRoofSegmentHit } from '../roof/segment-hit'
|
||||||
|
import { chimneyDefinition } from './definition'
|
||||||
|
import ChimneyPreview from './preview'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chimney placement tool. Listens to `roof:*` events; the preview
|
||||||
|
* follows the cursor across the segment with the segment's yaw applied
|
||||||
|
* (chimney itself stays world-vertical, so no slope tilt wrap). Click
|
||||||
|
* creates a new ChimneyNode parented to that segment with
|
||||||
|
* segment-local position.
|
||||||
|
*/
|
||||||
|
const tmpMatrix = new THREE.Matrix4()
|
||||||
|
const tmpInv = new THREE.Matrix4()
|
||||||
|
const tmpPos = new THREE.Vector3()
|
||||||
|
const tmpQuat = new THREE.Quaternion()
|
||||||
|
const tmpScale = new THREE.Vector3()
|
||||||
|
|
||||||
|
type SegmentTransform = {
|
||||||
|
position: [number, number, number]
|
||||||
|
quaternion: [number, number, number, number]
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChimneyTool = () => {
|
||||||
|
const activeBuildingId = useViewer((s) => s.selection.buildingId)
|
||||||
|
const setSelection = useViewer((s) => s.setSelection)
|
||||||
|
|
||||||
|
// Building-local matrix of the host segment — drives the ghost's
|
||||||
|
// outer-group transform so the preview lands inside the actual
|
||||||
|
// segment's frame (matches the real renderer in `renderer.tsx`).
|
||||||
|
const [segmentXform, setSegmentXform] = useState<SegmentTransform | null>(null)
|
||||||
|
// Cursor position expressed in segment-local coords. Layered inside
|
||||||
|
// the segment frame so the ghost slides with the cursor across the
|
||||||
|
// segment's footprint.
|
||||||
|
const [hitLocal, setHitLocal] = useState<[number, number, number] | null>(null)
|
||||||
|
const [previewSegment, setPreviewSegment] = useState<RoofSegmentNode | null>(null)
|
||||||
|
const lastSnapRef = useRef<[number, number] | null>(null)
|
||||||
|
|
||||||
|
const previewNode = useMemo(
|
||||||
|
() =>
|
||||||
|
ChimneyNode.parse({
|
||||||
|
...chimneyDefinition.defaults(),
|
||||||
|
name: 'Chimney',
|
||||||
|
position: [0, 0, 0],
|
||||||
|
rotation: 0,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeBuildingId) return
|
||||||
|
|
||||||
|
const computeSegmentXform = (segmentId: string): SegmentTransform | null => {
|
||||||
|
const buildingObj = sceneRegistry.nodes.get(activeBuildingId as AnyNodeId)
|
||||||
|
const segObj = sceneRegistry.nodes.get(segmentId as AnyNodeId)
|
||||||
|
if (!(buildingObj && segObj)) return null
|
||||||
|
buildingObj.updateWorldMatrix(true, false)
|
||||||
|
segObj.updateWorldMatrix(true, false)
|
||||||
|
tmpInv.copy(buildingObj.matrixWorld).invert()
|
||||||
|
tmpMatrix.multiplyMatrices(tmpInv, segObj.matrixWorld)
|
||||||
|
tmpMatrix.decompose(tmpPos, tmpQuat, tmpScale)
|
||||||
|
return {
|
||||||
|
position: [tmpPos.x, tmpPos.y, tmpPos.z],
|
||||||
|
quaternion: [tmpQuat.x, tmpQuat.y, tmpQuat.z, tmpQuat.w],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePreview = (event: RoofEvent) => {
|
||||||
|
const wx = event.position[0]
|
||||||
|
const wy = event.position[1]
|
||||||
|
const wz = event.position[2]
|
||||||
|
|
||||||
|
const sx = Math.round(wx * 20) / 20
|
||||||
|
const sz = Math.round(wz * 20) / 20
|
||||||
|
const prev = lastSnapRef.current
|
||||||
|
if (!prev || prev[0] !== sx || prev[1] !== sz) {
|
||||||
|
triggerSFX('sfx:grid-snap')
|
||||||
|
lastSnapRef.current = [sx, sz]
|
||||||
|
}
|
||||||
|
|
||||||
|
const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz)
|
||||||
|
if (!hit) return
|
||||||
|
|
||||||
|
const xform = computeSegmentXform(hit.segment.id)
|
||||||
|
if (!xform) return
|
||||||
|
setSegmentXform(xform)
|
||||||
|
setHitLocal([hit.localX, hit.localY, hit.localZ])
|
||||||
|
setPreviewSegment(hit.segment)
|
||||||
|
event.stopPropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onClick = (event: RoofEvent) => {
|
||||||
|
const hit = resolveRoofSegmentHit(
|
||||||
|
event.node as RoofNode,
|
||||||
|
event.position[0],
|
||||||
|
event.position[1],
|
||||||
|
event.position[2],
|
||||||
|
)
|
||||||
|
if (!hit) return
|
||||||
|
const state = useScene.getState()
|
||||||
|
|
||||||
|
const chimney = ChimneyNode.parse({
|
||||||
|
...chimneyDefinition.defaults(),
|
||||||
|
name: 'Chimney',
|
||||||
|
roofSegmentId: hit.segment.id,
|
||||||
|
position: [hit.localX, hit.localY, hit.localZ],
|
||||||
|
rotation: 0,
|
||||||
|
})
|
||||||
|
state.createNode(chimney, hit.segment.id as AnyNodeId)
|
||||||
|
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
||||||
|
setSelection({ selectedIds: [chimney.id] })
|
||||||
|
triggerSFX('sfx:item-place')
|
||||||
|
event.stopPropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
emitter.on('roof:move', updatePreview)
|
||||||
|
emitter.on('roof:enter', updatePreview)
|
||||||
|
emitter.on('roof:click', onClick)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
emitter.off('roof:move', updatePreview)
|
||||||
|
emitter.off('roof:enter', updatePreview)
|
||||||
|
emitter.off('roof:click', onClick)
|
||||||
|
}
|
||||||
|
}, [activeBuildingId, setSelection])
|
||||||
|
|
||||||
|
if (!activeBuildingId || !segmentXform || !hitLocal || !previewSegment) return null
|
||||||
|
|
||||||
|
// Outer group mirrors the real renderer's `position={segment.position}
|
||||||
|
// rotation-y={segment.rotation}` chain by composing the segment's
|
||||||
|
// building-local matrix (which walks roof + level + segment). Inner
|
||||||
|
// group offsets by the cursor's segment-local x/z so the chimney
|
||||||
|
// geometry (built with `position[0,2] = 0`) lands under the cursor.
|
||||||
|
return (
|
||||||
|
<group position={segmentXform.position} quaternion={segmentXform.quaternion}>
|
||||||
|
<group position={[hitLocal[0], 0, hitLocal[2]]}>
|
||||||
|
<ChimneyPreview node={previewNode} segment={previewSegment} />
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChimneyTool
|
||||||
@@ -318,17 +318,104 @@ export default function ColumnPanel() {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<select
|
<div className="grid grid-cols-3 gap-2 px-1 pt-1">
|
||||||
className={SELECT_CLASS}
|
{(
|
||||||
onChange={(event) =>
|
[
|
||||||
handleUpdate({ crossSection: event.target.value as ColumnNode['crossSection'] })
|
{
|
||||||
}
|
value: 'round',
|
||||||
value={node.crossSection}
|
label: 'Round',
|
||||||
>
|
icon: (
|
||||||
<option value="round">Round</option>
|
<svg
|
||||||
<option value="square">Square</option>
|
aria-hidden="true"
|
||||||
<option value="rectangular">Rectangular</option>
|
fill="none"
|
||||||
</select>
|
height="22"
|
||||||
|
viewBox="0 0 22 22"
|
||||||
|
width="22"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="7.5" stroke="currentColor" strokeWidth="1.5" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'square',
|
||||||
|
label: 'Square',
|
||||||
|
icon: (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
fill="none"
|
||||||
|
height="22"
|
||||||
|
viewBox="0 0 22 22"
|
||||||
|
width="22"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
height="15"
|
||||||
|
rx="1.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
width="15"
|
||||||
|
x="3.5"
|
||||||
|
y="3.5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'rectangular',
|
||||||
|
label: 'Rectangular',
|
||||||
|
icon: (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
fill="none"
|
||||||
|
height="22"
|
||||||
|
viewBox="0 0 22 22"
|
||||||
|
width="22"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
height="11"
|
||||||
|
rx="1.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
width="16"
|
||||||
|
x="3"
|
||||||
|
y="5.5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
] as {
|
||||||
|
value: ColumnNode['crossSection']
|
||||||
|
label: string
|
||||||
|
icon: React.ReactNode
|
||||||
|
}[]
|
||||||
|
).map((option) => {
|
||||||
|
const isSelected = node.crossSection === option.value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'group flex flex-col items-center justify-center gap-1.5 rounded-lg border py-2.5 transition-all',
|
||||||
|
isSelected
|
||||||
|
? 'border-orange-400/60 bg-orange-400/10 text-foreground shadow-[0_0_0_1px_rgba(251,146,60,0.25)_inset]'
|
||||||
|
: 'border-border/50 bg-[#2C2C2E] text-muted-foreground hover:border-border hover:bg-[#3e3e3e] hover:text-foreground',
|
||||||
|
)}
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => handleUpdate({ crossSection: option.value })}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'flex h-7 w-7 items-center justify-center',
|
||||||
|
isSelected ? 'text-orange-300' : 'text-muted-foreground/80',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{option.icon}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-[11px] leading-none tracking-wide">
|
||||||
|
{option.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
<SliderControl
|
<SliderControl
|
||||||
label="Edge Softness"
|
label="Edge Softness"
|
||||||
max={0.12}
|
max={0.12}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import {
|
||||||
|
buildDormerGhostGeometry,
|
||||||
|
dormerSupportsArch,
|
||||||
|
dormerSupportsCornerRadii,
|
||||||
|
} from '../geometry'
|
||||||
|
import { DormerNode } from '../schema'
|
||||||
|
|
||||||
|
describe('buildDormerGhostGeometry (placement preview)', () => {
|
||||||
|
test('returns a buffer geometry with position attribute', () => {
|
||||||
|
const geo = buildDormerGhostGeometry(DormerNode.parse({}))
|
||||||
|
expect(geo.getAttribute('position').count).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('width / depth drive the silhouette footprint', () => {
|
||||||
|
const geo = buildDormerGhostGeometry(DormerNode.parse({ width: 2, depth: 4, height: 1 }))
|
||||||
|
geo.computeBoundingBox()
|
||||||
|
const bb = geo.boundingBox!
|
||||||
|
expect(bb.max.x - bb.min.x).toBeCloseTo(2)
|
||||||
|
expect(bb.max.z - bb.min.z).toBeCloseTo(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('roofHeight raises the gable peak', () => {
|
||||||
|
const a = buildDormerGhostGeometry(DormerNode.parse({ roofHeight: 0.5 }))
|
||||||
|
const b = buildDormerGhostGeometry(DormerNode.parse({ roofHeight: 1.5 }))
|
||||||
|
a.computeBoundingBox()
|
||||||
|
b.computeBoundingBox()
|
||||||
|
expect(b.boundingBox!.max.y).toBeGreaterThan(a.boundingBox!.max.y)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('windowShape predicates', () => {
|
||||||
|
test('dormerSupportsArch only when windowShape=arch', () => {
|
||||||
|
expect(dormerSupportsArch(DormerNode.parse({ windowShape: 'arch' }))).toBe(true)
|
||||||
|
expect(dormerSupportsArch(DormerNode.parse({ windowShape: 'rounded' }))).toBe(false)
|
||||||
|
expect(dormerSupportsArch(DormerNode.parse({ windowShape: 'rectangle' }))).toBe(false)
|
||||||
|
})
|
||||||
|
test('dormerSupportsCornerRadii only when windowShape=rounded', () => {
|
||||||
|
expect(dormerSupportsCornerRadii(DormerNode.parse({ windowShape: 'rounded' }))).toBe(true)
|
||||||
|
expect(dormerSupportsCornerRadii(DormerNode.parse({ windowShape: 'arch' }))).toBe(false)
|
||||||
|
expect(dormerSupportsCornerRadii(DormerNode.parse({ windowShape: 'rectangle' }))).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import { getEffectiveDormerSurfaceMaterial } from '@pascal-app/core'
|
||||||
|
import { DormerNode } from '../schema'
|
||||||
|
|
||||||
|
describe('DormerNode schema', () => {
|
||||||
|
test('parses with defaults', () => {
|
||||||
|
const parsed = DormerNode.parse({})
|
||||||
|
expect(parsed.type).toBe('dormer')
|
||||||
|
expect(parsed.id).toMatch(/^dormer_/)
|
||||||
|
expect(parsed.width).toBe(1.21)
|
||||||
|
expect(parsed.depth).toBe(1.55)
|
||||||
|
expect(parsed.height).toBe(0)
|
||||||
|
expect(parsed.roofType).toBe('gable')
|
||||||
|
expect(parsed.windowShape).toBe('rectangle')
|
||||||
|
expect(parsed.windowSill).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('windowColumns / windowRows clamped to [1, 8]', () => {
|
||||||
|
expect(() => DormerNode.parse({ windowColumns: 0 })).toThrow()
|
||||||
|
expect(() => DormerNode.parse({ windowColumns: 9 })).toThrow()
|
||||||
|
expect(() => DormerNode.parse({ windowRows: 1.5 })).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('windowCornerRadii round-trips as tuple of 4', () => {
|
||||||
|
const parsed = DormerNode.parse({ windowCornerRadii: [0.1, 0.2, 0.3, 0.4] })
|
||||||
|
expect(parsed.windowCornerRadii).toEqual([0.1, 0.2, 0.3, 0.4])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getEffectiveDormerSurfaceMaterial', () => {
|
||||||
|
test('top role prefers topMaterialPreset, falls back to legacy materialPreset', () => {
|
||||||
|
const node = DormerNode.parse({
|
||||||
|
materialPreset: 'red',
|
||||||
|
topMaterialPreset: 'blue',
|
||||||
|
})
|
||||||
|
expect(getEffectiveDormerSurfaceMaterial(node, 'top').materialPreset).toBe('blue')
|
||||||
|
expect(getEffectiveDormerSurfaceMaterial(node, 'wall').materialPreset).toBe('red')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('side role cross-falls-back to wallMaterialPreset', () => {
|
||||||
|
const node = DormerNode.parse({ wallMaterialPreset: 'wallpaper' })
|
||||||
|
expect(getEffectiveDormerSurfaceMaterial(node, 'side').materialPreset).toBe('wallpaper')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('wall role cross-falls-back to sideMaterialPreset', () => {
|
||||||
|
const node = DormerNode.parse({ sideMaterialPreset: 'cedar' })
|
||||||
|
expect(getEffectiveDormerSurfaceMaterial(node, 'wall').materialPreset).toBe('cedar')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('all three roles fall back to legacy materialPreset when nothing set', () => {
|
||||||
|
const node = DormerNode.parse({ materialPreset: 'stucco' })
|
||||||
|
expect(getEffectiveDormerSurfaceMaterial(node, 'top').materialPreset).toBe('stucco')
|
||||||
|
expect(getEffectiveDormerSurfaceMaterial(node, 'side').materialPreset).toBe('stucco')
|
||||||
|
expect(getEffectiveDormerSurfaceMaterial(node, 'wall').materialPreset).toBe('stucco')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,841 @@
|
|||||||
|
import {
|
||||||
|
type DormerNode,
|
||||||
|
getActiveRoofHeight,
|
||||||
|
getPitchFromActiveRoofHeight,
|
||||||
|
ROOF_SHAPE_DEFAULTS,
|
||||||
|
type RoofSegmentNode,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import {
|
||||||
|
ADDITION,
|
||||||
|
Brush,
|
||||||
|
computeGeometryBoundsTree,
|
||||||
|
csgEvaluator,
|
||||||
|
csgGeometry,
|
||||||
|
csgMaterials,
|
||||||
|
getRoofSegmentBrushes,
|
||||||
|
mapRoofGroupMaterialIndex,
|
||||||
|
prepareBrushForCSG,
|
||||||
|
remapRoofShellFaces,
|
||||||
|
roofCsgDummyMats,
|
||||||
|
SUBTRACTION,
|
||||||
|
} from '@pascal-app/viewer'
|
||||||
|
import * as THREE from 'three'
|
||||||
|
import {
|
||||||
|
mergeGeometries,
|
||||||
|
mergeVertices,
|
||||||
|
} 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).
|
||||||
|
const DORMER_DROP_BELOW = 2
|
||||||
|
|
||||||
|
function dormerSkirtHeight(dormer: DormerNode): number {
|
||||||
|
return Math.max(0.05, dormer.wallSkirtHeight ?? DORMER_DROP_BELOW)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DORMER_GABLE_MATERIAL_INDEX = 4
|
||||||
|
|
||||||
|
const _yAxis = new THREE.Vector3(0, 1, 0)
|
||||||
|
const _scale = new THREE.Vector3(1, 1, 1)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cheap silhouette geometry. Used as a fallback when CSG cannot run
|
||||||
|
* (missing host brushes, thrown exception, degenerate inputs) and as
|
||||||
|
* the live preview during slider drags so we don't re-run CSG on every
|
||||||
|
* pointer move. Also used by the placement / move-tool ghost.
|
||||||
|
*
|
||||||
|
* Builds a rectangular body + simple roof in dormer-mesh-local. For
|
||||||
|
* `flat` dormers the roof triangle is skipped. Other roof types use
|
||||||
|
* the gable approximation — it's a rough silhouette by design.
|
||||||
|
*
|
||||||
|
* The wall sits at material slot 0 and the roof at slot 3 so it picks
|
||||||
|
* up the same material array the renderer passes for the CSG output.
|
||||||
|
*/
|
||||||
|
export function buildDormerFallbackGeometry(dormer: DormerNode): THREE.BufferGeometry {
|
||||||
|
const w = Math.max(0.05, dormer.width)
|
||||||
|
const d = Math.max(0.05, dormer.depth)
|
||||||
|
const wallH = Math.max(0.05, dormer.height)
|
||||||
|
const roofH = Math.max(0, dormer.roofHeight)
|
||||||
|
const skirt = dormerSkirtHeight(dormer)
|
||||||
|
const isFlat = dormer.roofType === 'flat' || roofH === 0
|
||||||
|
|
||||||
|
// Body box: foot at y = -skirt, top at y = wallH.
|
||||||
|
const body = new THREE.BoxGeometry(w, wallH + skirt, d)
|
||||||
|
body.translate(0, (wallH - skirt) / 2, 0)
|
||||||
|
const bIdx = body.getIndex()?.count ?? 0
|
||||||
|
body.clearGroups()
|
||||||
|
body.addGroup(0, bIdx, 0)
|
||||||
|
|
||||||
|
if (isFlat) {
|
||||||
|
if (!body.getAttribute('normal')) body.computeVertexNormals()
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roof: extruded triangle from eave (y = wallH) to peak (y = wallH + roofH).
|
||||||
|
// Apex points along +Y, base spans the width. Extrude along Z (depth).
|
||||||
|
const roofShape = new THREE.Shape()
|
||||||
|
roofShape.moveTo(-w / 2, 0)
|
||||||
|
roofShape.lineTo(w / 2, 0)
|
||||||
|
roofShape.lineTo(0, roofH)
|
||||||
|
roofShape.lineTo(-w / 2, 0)
|
||||||
|
const roof = new THREE.ExtrudeGeometry(roofShape, { depth: d, bevelEnabled: false })
|
||||||
|
roof.translate(0, wallH, -d / 2)
|
||||||
|
|
||||||
|
const rIdx = roof.getIndex()?.count ?? 0
|
||||||
|
roof.clearGroups()
|
||||||
|
roof.addGroup(0, rIdx, 3)
|
||||||
|
|
||||||
|
const merged = mergeGeometries([body, roof], true) ?? body
|
||||||
|
body.dispose()
|
||||||
|
roof.dispose()
|
||||||
|
if (!merged.getAttribute('normal')) merged.computeVertexNormals()
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDormerArchShape(w: number, h: number, archHeight: number): THREE.Shape {
|
||||||
|
const hw = w / 2
|
||||||
|
const hh = h / 2
|
||||||
|
const clampedArch = Math.min(Math.max(archHeight, 0.01), Math.max(h, 0.01))
|
||||||
|
const springY = hh - clampedArch
|
||||||
|
const segments = 32
|
||||||
|
|
||||||
|
const shape = new THREE.Shape()
|
||||||
|
shape.moveTo(-hw, -hh)
|
||||||
|
shape.lineTo(hw, -hh)
|
||||||
|
shape.lineTo(hw, springY)
|
||||||
|
for (let i = 1; i <= segments; i++) {
|
||||||
|
const x = hw + (-hw - hw) * (i / segments)
|
||||||
|
const t = Math.min(Math.abs(x) / hw, 1)
|
||||||
|
const y = springY + clampedArch * Math.sqrt(Math.max(1 - t * t, 0))
|
||||||
|
shape.lineTo(x, y)
|
||||||
|
}
|
||||||
|
shape.lineTo(-hw, -hh)
|
||||||
|
shape.closePath()
|
||||||
|
return shape
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeDormerCornerRadii(
|
||||||
|
radii: [number, number, number, number],
|
||||||
|
w: number,
|
||||||
|
h: number,
|
||||||
|
): [number, number, number, number] {
|
||||||
|
const r = radii.map((v) => Math.max(v, 0)) as [number, number, number, number]
|
||||||
|
const scale = Math.min(
|
||||||
|
1,
|
||||||
|
Math.max(w, 0) / Math.max(r[0] + r[1], 1e-6),
|
||||||
|
Math.max(w, 0) / Math.max(r[3] + r[2], 1e-6),
|
||||||
|
Math.max(h, 0) / Math.max(r[0] + r[3], 1e-6),
|
||||||
|
Math.max(h, 0) / Math.max(r[1] + r[2], 1e-6),
|
||||||
|
)
|
||||||
|
if (scale >= 1) return r
|
||||||
|
return r.map((v) => v * scale) as [number, number, number, number]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDormerRoundedShape(
|
||||||
|
w: number,
|
||||||
|
h: number,
|
||||||
|
radii: [number, number, number, number],
|
||||||
|
): THREE.Shape {
|
||||||
|
const hw = w / 2
|
||||||
|
const hh = h / 2
|
||||||
|
const [tl, tr, br, bl] = normalizeDormerCornerRadii(radii, w, h)
|
||||||
|
|
||||||
|
const shape = new THREE.Shape()
|
||||||
|
shape.moveTo(-hw + bl, -hh)
|
||||||
|
shape.lineTo(hw - br, -hh)
|
||||||
|
if (br > 0) shape.absarc(hw - br, -hh + br, br, -Math.PI / 2, 0, false)
|
||||||
|
else shape.lineTo(hw, -hh)
|
||||||
|
shape.lineTo(hw, hh - tr)
|
||||||
|
if (tr > 0) shape.absarc(hw - tr, hh - tr, tr, 0, Math.PI / 2, false)
|
||||||
|
else shape.lineTo(hw, hh)
|
||||||
|
shape.lineTo(-hw + tl, hh)
|
||||||
|
if (tl > 0) shape.absarc(-hw + tl, hh - tl, tl, Math.PI / 2, Math.PI, false)
|
||||||
|
else shape.lineTo(-hw, hh)
|
||||||
|
shape.lineTo(-hw, -hh + bl)
|
||||||
|
if (bl > 0) shape.absarc(-hw + bl, -hh + bl, bl, Math.PI, (3 * Math.PI) / 2, false)
|
||||||
|
else shape.lineTo(-hw, -hh)
|
||||||
|
shape.closePath()
|
||||||
|
return shape
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDormerRadii(
|
||||||
|
dormer: DormerNode,
|
||||||
|
w: number,
|
||||||
|
h: number,
|
||||||
|
): [number, number, number, number] {
|
||||||
|
return normalizeDormerCornerRadii(dormer.windowCornerRadii, w, h)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDormerWindowCutGeometry(
|
||||||
|
dormer: DormerNode,
|
||||||
|
w: number,
|
||||||
|
h: number,
|
||||||
|
depth: number,
|
||||||
|
): THREE.BufferGeometry {
|
||||||
|
const shape = dormer.windowShape ?? 'rectangle'
|
||||||
|
if (shape === 'arch') {
|
||||||
|
const s = createDormerArchShape(w, h, dormer.windowArchHeight ?? 0.35)
|
||||||
|
const geo = new THREE.ExtrudeGeometry(s, { depth, bevelEnabled: false, curveSegments: 24 })
|
||||||
|
geo.translate(0, 0, -depth / 2)
|
||||||
|
return geo
|
||||||
|
}
|
||||||
|
if (shape === 'rounded') {
|
||||||
|
const radii = resolveDormerRadii(dormer, w, h)
|
||||||
|
const s = createDormerRoundedShape(w, h, radii)
|
||||||
|
const geo = new THREE.ExtrudeGeometry(s, { depth, bevelEnabled: false, curveSegments: 24 })
|
||||||
|
geo.translate(0, 0, -depth / 2)
|
||||||
|
return geo
|
||||||
|
}
|
||||||
|
return new THREE.BoxGeometry(w, h, depth)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which faces of a dormer are exposed (not fully buried in the host
|
||||||
|
* roof). "front" = mesh-local +Z, "back" = mesh-local −Z (after the
|
||||||
|
* +π/2 yaw bake for non-shed roofs). A face is exposed when the
|
||||||
|
* dormer's total wall top exceeds the host roof surface at that face's
|
||||||
|
* Z position.
|
||||||
|
*/
|
||||||
|
export function getDormerExposedFaces(
|
||||||
|
dormer: DormerNode,
|
||||||
|
hostSegment: RoofSegmentNode,
|
||||||
|
): { front: boolean; back: boolean } {
|
||||||
|
const halfDepth = dormer.depth / 2
|
||||||
|
const dormerZ = dormer.position[2] ?? 0
|
||||||
|
const dormerY = dormer.position[1] ?? 0
|
||||||
|
const rot = dormer.rotation ?? 0
|
||||||
|
|
||||||
|
// Gable-face centres in segment-local Z (accounts for dormer yaw).
|
||||||
|
const frontZ = dormerZ + halfDepth * Math.cos(rot)
|
||||||
|
const backZ = dormerZ - halfDepth * Math.cos(rot)
|
||||||
|
|
||||||
|
const dormerWallTop = dormerY + dormer.height
|
||||||
|
|
||||||
|
const hostWh = hostSegment.wallHeight ?? 0.5
|
||||||
|
const hostRh = getActiveRoofHeight(hostSegment)
|
||||||
|
const hostDepth = hostSegment.depth ?? 4
|
||||||
|
|
||||||
|
const roofHeightAtZ = (segZ: number): number => {
|
||||||
|
const hostType = hostSegment.roofType ?? 'gable'
|
||||||
|
if (hostType === 'flat') return hostWh
|
||||||
|
if (hostType === 'shed') {
|
||||||
|
const t = Math.max(0, Math.min(1, (segZ + hostDepth / 2) / Math.max(hostDepth, 0.01)))
|
||||||
|
return hostWh + hostRh * (1 - t)
|
||||||
|
}
|
||||||
|
const halfD = Math.max(hostDepth / 2, 0.01)
|
||||||
|
const t = Math.max(0, Math.min(1, Math.abs(segZ) / halfD))
|
||||||
|
return hostWh + hostRh * (1 - t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A face is "exposed" only if the dormer's wall actually pokes above
|
||||||
|
// the host roof there by a meaningful amount — otherwise the wall is
|
||||||
|
// CSG-buried and any window we render at that face will hover with
|
||||||
|
// no wall behind it. A 5cm threshold suppresses the borderline-cases
|
||||||
|
// where the wall top is essentially level with the slope.
|
||||||
|
const minPokeOut = 0.05
|
||||||
|
return {
|
||||||
|
front: dormerWallTop - roofHeightAtZ(frontZ) > minPokeOut,
|
||||||
|
back: dormerWallTop - roofHeightAtZ(backZ) > minPokeOut,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed dimensions for the window opening on a dormer's gable face.
|
||||||
|
* The skirt (the wall extension below the eave used for CSG-trim) is
|
||||||
|
* `DORMER_DROP_BELOW` tall, so the window sits within that band.
|
||||||
|
*/
|
||||||
|
export function getDormerSkirtWindowDims(dormer: DormerNode): {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
centerY: number
|
||||||
|
offsetX: number
|
||||||
|
} {
|
||||||
|
const skirtH = dormerSkirtHeight(dormer)
|
||||||
|
const maxW = Math.max(dormer.width - 0.1, 0.1)
|
||||||
|
const maxH = Math.max(skirtH - 0.1, 0.1)
|
||||||
|
const width = Math.min(Math.max(dormer.windowWidth ?? 1.2, 0.1), maxW)
|
||||||
|
const height = Math.min(Math.max(dormer.windowHeight ?? 1.2, 0.1), maxH)
|
||||||
|
const offsetX = dormer.windowOffsetX ?? 0
|
||||||
|
const offsetY = dormer.windowOffsetY ?? 0
|
||||||
|
const centerY = -(skirtH / 2) + offsetY
|
||||||
|
return { width, height, centerY, offsetX }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the trimmed dormer geometry hosted on a roof segment. The
|
||||||
|
* dormer's own walls+roof are generated via `getRoofSegmentBrushes`
|
||||||
|
* on a virtual segment, then the host segment's filled solid is
|
||||||
|
* CSG-subtracted in dormer-mesh-local space. Window openings are then
|
||||||
|
* subtracted on each exposed gable face.
|
||||||
|
*/
|
||||||
|
export function generateDormerGeometry(
|
||||||
|
dormer: DormerNode,
|
||||||
|
hostSegment: RoofSegmentNode,
|
||||||
|
): THREE.BufferGeometry {
|
||||||
|
const isShed = dormer.roofType === 'shed'
|
||||||
|
const yawBake = isShed ? 0 : Math.PI / 2
|
||||||
|
const segWidth = isShed ? dormer.width : dormer.depth
|
||||||
|
const segDepth = isShed ? dormer.depth : dormer.width
|
||||||
|
const skirt = dormerSkirtHeight(dormer)
|
||||||
|
|
||||||
|
const vsWidth = Math.max(0.05, segWidth)
|
||||||
|
const vsDepth = Math.max(0.05, segDepth)
|
||||||
|
const vsActiveRh = Math.max(0, dormer.roofHeight)
|
||||||
|
const virtualSegment: RoofSegmentNode = {
|
||||||
|
object: 'node',
|
||||||
|
id: `rseg_dormer_${dormer.id}` as RoofSegmentNode['id'],
|
||||||
|
type: 'roof-segment',
|
||||||
|
parentId: null,
|
||||||
|
visible: true,
|
||||||
|
metadata: null,
|
||||||
|
children: [],
|
||||||
|
position: [0, 0, 0],
|
||||||
|
rotation: 0,
|
||||||
|
roofType: dormer.roofType,
|
||||||
|
width: vsWidth,
|
||||||
|
depth: vsDepth,
|
||||||
|
wallHeight: Math.max(0.05, dormer.height) + skirt,
|
||||||
|
// The dormer schema still expresses its roof as a height; translate
|
||||||
|
// to the pitch the segment math now expects so the virtual segment
|
||||||
|
// produces an identical peak.
|
||||||
|
pitch: getPitchFromActiveRoofHeight({
|
||||||
|
roofType: dormer.roofType,
|
||||||
|
width: vsWidth,
|
||||||
|
depth: vsDepth,
|
||||||
|
roofHeight: vsActiveRh,
|
||||||
|
}),
|
||||||
|
// Dormers don't expose multi-slope shape tuning; bake the schema
|
||||||
|
// defaults so the virtualSegment renders the canonical kink positions.
|
||||||
|
...ROOF_SHAPE_DEFAULTS,
|
||||||
|
wallThickness: 0.05,
|
||||||
|
deckThickness: 0.04,
|
||||||
|
overhang: 0.08,
|
||||||
|
shingleThickness: 0.02,
|
||||||
|
}
|
||||||
|
|
||||||
|
const dormerBrushes = getRoofSegmentBrushes(virtualSegment)
|
||||||
|
if (!dormerBrushes) {
|
||||||
|
// biome-ignore lint/suspicious/noConsole: keep diagnostic — fallback path.
|
||||||
|
console.warn('[dormer] getRoofSegmentBrushes returned null; using fallback silhouette.')
|
||||||
|
return buildDormerFallbackGeometry(dormer)
|
||||||
|
}
|
||||||
|
|
||||||
|
let resultGeo = new THREE.BufferGeometry()
|
||||||
|
let dormerSolid: Brush | null = null
|
||||||
|
let hostSolid: Brush | null = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hollowWall = csgEvaluator.evaluate(
|
||||||
|
dormerBrushes.wallBrush,
|
||||||
|
dormerBrushes.innerBrush,
|
||||||
|
SUBTRACTION,
|
||||||
|
) as Brush
|
||||||
|
const shinDeck = csgEvaluator.evaluate(
|
||||||
|
dormerBrushes.shinSlab,
|
||||||
|
dormerBrushes.deckSlab,
|
||||||
|
ADDITION,
|
||||||
|
) as Brush
|
||||||
|
dormerSolid = csgEvaluator.evaluate(shinDeck, hollowWall, ADDITION) as Brush
|
||||||
|
hollowWall.geometry.dispose()
|
||||||
|
shinDeck.geometry.dispose()
|
||||||
|
|
||||||
|
const bakeMatrix = new THREE.Matrix4().compose(
|
||||||
|
new THREE.Vector3(0, -skirt, 0),
|
||||||
|
new THREE.Quaternion().setFromAxisAngle(_yAxis, yawBake),
|
||||||
|
_scale,
|
||||||
|
)
|
||||||
|
csgGeometry(dormerSolid).applyMatrix4(bakeMatrix)
|
||||||
|
prepareBrushForCSG(dormerSolid)
|
||||||
|
|
||||||
|
const hostBrushes = getRoofSegmentBrushes(hostSegment)
|
||||||
|
if (hostBrushes) {
|
||||||
|
const wallPlusDeck = csgEvaluator.evaluate(
|
||||||
|
hostBrushes.wallBrush,
|
||||||
|
hostBrushes.deckSlab,
|
||||||
|
ADDITION,
|
||||||
|
) as Brush
|
||||||
|
hostSolid = csgEvaluator.evaluate(wallPlusDeck, hostBrushes.shinSlab, ADDITION) as Brush
|
||||||
|
wallPlusDeck.geometry.dispose()
|
||||||
|
hostBrushes.deckSlab.geometry.dispose()
|
||||||
|
hostBrushes.shinSlab.geometry.dispose()
|
||||||
|
hostBrushes.wallBrush.geometry.dispose()
|
||||||
|
hostBrushes.innerBrush.geometry.dispose()
|
||||||
|
|
||||||
|
// Union a deep ground box covering the host footprint so the
|
||||||
|
// dormer's skirt (extending below y=0) has something to subtract.
|
||||||
|
const groundMargin = Math.max(hostSegment.width, hostSegment.depth) * 2 + 4
|
||||||
|
const groundBoxGeo = new THREE.BoxGeometry(groundMargin, 100, groundMargin)
|
||||||
|
groundBoxGeo.translate(0, -50, 0)
|
||||||
|
const indexCount = groundBoxGeo.getIndex()?.count ?? 0
|
||||||
|
groundBoxGeo.clearGroups()
|
||||||
|
groundBoxGeo.addGroup(0, indexCount, 0)
|
||||||
|
computeGeometryBoundsTree(groundBoxGeo)
|
||||||
|
const groundBrush = new Brush(groundBoxGeo, roofCsgDummyMats[0])
|
||||||
|
groundBrush.updateMatrixWorld()
|
||||||
|
const fullTrim = csgEvaluator.evaluate(hostSolid, groundBrush, ADDITION) as Brush
|
||||||
|
hostSolid.geometry.dispose()
|
||||||
|
groundBrush.geometry.dispose()
|
||||||
|
hostSolid = fullTrim
|
||||||
|
|
||||||
|
// Host brushes live in segment-local. Bring them into
|
||||||
|
// dormer-mesh-local by inverting T(node.position) · R_y(node.rotation).
|
||||||
|
const segToMesh = new THREE.Matrix4()
|
||||||
|
.compose(
|
||||||
|
new THREE.Vector3(
|
||||||
|
dormer.position[0] ?? 0,
|
||||||
|
dormer.position[1] ?? 0,
|
||||||
|
dormer.position[2] ?? 0,
|
||||||
|
),
|
||||||
|
new THREE.Quaternion().setFromAxisAngle(_yAxis, dormer.rotation),
|
||||||
|
_scale,
|
||||||
|
)
|
||||||
|
.invert()
|
||||||
|
csgGeometry(hostSolid).applyMatrix4(segToMesh)
|
||||||
|
prepareBrushForCSG(hostSolid)
|
||||||
|
|
||||||
|
const trimmed = csgEvaluator.evaluate(dormerSolid, hostSolid, SUBTRACTION) as Brush
|
||||||
|
dormerSolid.geometry.dispose()
|
||||||
|
hostSolid.geometry.dispose()
|
||||||
|
hostSolid = null
|
||||||
|
dormerSolid = trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cut window openings on exposed gable faces.
|
||||||
|
const exposed = getDormerExposedFaces(dormer, hostSegment)
|
||||||
|
const skirtWin = getDormerSkirtWindowDims(dormer)
|
||||||
|
const gableHalfZ = dormer.depth / 2
|
||||||
|
const cutDepth = 0.4
|
||||||
|
|
||||||
|
const cutFace = (zSign: number) => {
|
||||||
|
const cutGeo = createDormerWindowCutGeometry(
|
||||||
|
dormer,
|
||||||
|
skirtWin.width,
|
||||||
|
skirtWin.height,
|
||||||
|
cutDepth,
|
||||||
|
)
|
||||||
|
cutGeo.translate(skirtWin.offsetX, skirtWin.centerY, zSign * gableHalfZ)
|
||||||
|
if (!cutGeo.getIndex()) {
|
||||||
|
const posCount = cutGeo.getAttribute('position').count
|
||||||
|
const idx = new Uint32Array(posCount)
|
||||||
|
for (let i = 0; i < posCount; i++) idx[i] = i
|
||||||
|
cutGeo.setIndex(new THREE.BufferAttribute(idx, 1))
|
||||||
|
}
|
||||||
|
const idxCount = cutGeo.getIndex()!.count
|
||||||
|
cutGeo.clearGroups()
|
||||||
|
cutGeo.addGroup(0, idxCount, 0)
|
||||||
|
computeGeometryBoundsTree(cutGeo)
|
||||||
|
const brush = new Brush(cutGeo, roofCsgDummyMats[0])
|
||||||
|
brush.updateMatrixWorld()
|
||||||
|
const result = csgEvaluator.evaluate(dormerSolid!, brush, SUBTRACTION) as Brush
|
||||||
|
dormerSolid!.geometry.dispose()
|
||||||
|
brush.geometry.dispose()
|
||||||
|
dormerSolid = result
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exposed.front) cutFace(+1)
|
||||||
|
if (exposed.back) cutFace(-1)
|
||||||
|
|
||||||
|
resultGeo = csgGeometry(dormerSolid)
|
||||||
|
const resultMaterials = csgMaterials(dormerSolid)
|
||||||
|
|
||||||
|
const matToIndex = new Map<THREE.Material, number>([
|
||||||
|
[roofCsgDummyMats[0], 0],
|
||||||
|
[roofCsgDummyMats[1], 1],
|
||||||
|
[roofCsgDummyMats[2], 2],
|
||||||
|
[roofCsgDummyMats[3], 3],
|
||||||
|
])
|
||||||
|
for (const group of resultGeo.groups) {
|
||||||
|
group.materialIndex = mapRoofGroupMaterialIndex(
|
||||||
|
group.materialIndex,
|
||||||
|
resultMaterials,
|
||||||
|
matToIndex,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
remapRoofShellFaces(resultGeo, virtualSegment)
|
||||||
|
splitDormerGableMaterial(resultGeo, dormer.height, DORMER_GABLE_MATERIAL_INDEX)
|
||||||
|
} catch (e) {
|
||||||
|
// biome-ignore lint/suspicious/noConsole: dormer CSG can throw; keep diagnostic.
|
||||||
|
console.error('[dormer] CSG failed, falling back to silhouette:', e)
|
||||||
|
if (dormerSolid) {
|
||||||
|
try {
|
||||||
|
dormerSolid.geometry.dispose()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
if (hostSolid) {
|
||||||
|
try {
|
||||||
|
hostSolid.geometry.dispose()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return buildDormerFallbackGeometry(dormer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If CSG produced zero triangles (host fully buried it, or one of the
|
||||||
|
// boolean ops collapsed to empty), fall back to the silhouette so the
|
||||||
|
// dormer is at least visible.
|
||||||
|
const triCount = resultGeo.getIndex()?.count ?? resultGeo.getAttribute('position')?.count ?? 0
|
||||||
|
if (triCount === 0) {
|
||||||
|
// biome-ignore lint/suspicious/noConsole: keep diagnostic — empty CSG.
|
||||||
|
console.warn('[dormer] CSG produced empty geometry; using fallback silhouette.')
|
||||||
|
return buildDormerFallbackGeometry(dormer)
|
||||||
|
}
|
||||||
|
|
||||||
|
resultGeo.computeVertexNormals()
|
||||||
|
ensureUv2Attribute(resultGeo)
|
||||||
|
return resultGeo
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the dormer cut shape in dormer-mesh-local coordinates. The
|
||||||
|
* returned geometry is centered at X=Z=0 and spans Y ∈ [-skirt, peak]
|
||||||
|
* — the caller layers on the dormer's yaw + position to bring it into
|
||||||
|
* segment-local space.
|
||||||
|
*
|
||||||
|
* Shapes per roof type:
|
||||||
|
* - **flat**: a plain box (top flush with the eave; the
|
||||||
|
* dormer body has no roof above wallH).
|
||||||
|
* - **shed**: trapezoid in YZ, extruded along X. Eave
|
||||||
|
* at z=+d/2 (y=wallH), peak at z=-d/2
|
||||||
|
* (y=wallH+roofH) — matches the slope
|
||||||
|
* direction the dormer body uses.
|
||||||
|
* - **gable / gambrel**: pentagon (rectangle + symmetric triangle)
|
||||||
|
* in XY, extruded along Z. Ridge runs
|
||||||
|
* along Z (mesh-Z = virtualSegment-X after
|
||||||
|
* the yaw bake).
|
||||||
|
* - **hip / dutch / mansard**: pyramid — rectangular base, single
|
||||||
|
* apex at the peak. Narrows on all four
|
||||||
|
* sides.
|
||||||
|
*
|
||||||
|
* Gambrel / dutch / mansard fall back to gable / hip rather than the
|
||||||
|
* legacy CSG-derived geometry, because three-bvh-csg's three-way
|
||||||
|
* subtraction in the merged-roof loop can't accept CSG-derived
|
||||||
|
* brushes without corrupting the result. The dormer body itself still
|
||||||
|
* carries the precise per-type shape; the cut just needs to clear
|
||||||
|
* enough of the host shell for the body to sit cleanly.
|
||||||
|
*/
|
||||||
|
export function buildDormerCutShape(
|
||||||
|
roofType: DormerNode['roofType'],
|
||||||
|
innerW: number,
|
||||||
|
innerD: number,
|
||||||
|
skirt: number,
|
||||||
|
wallH: number,
|
||||||
|
roofH: number,
|
||||||
|
): THREE.BufferGeometry {
|
||||||
|
const hw = innerW / 2
|
||||||
|
const hd = innerD / 2
|
||||||
|
|
||||||
|
if (roofType === 'flat') {
|
||||||
|
const geo = new THREE.BoxGeometry(innerW, skirt + wallH, innerD)
|
||||||
|
geo.translate(0, (wallH - skirt) / 2, 0)
|
||||||
|
return geo
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roofType === 'shed') {
|
||||||
|
// Trapezoid in shape XY → extruded along Z (shape's natural
|
||||||
|
// extrude axis) → rotated +π/2 around Y so the shape's X axis
|
||||||
|
// ends up along mesh-(-Z) and the extrusion ends up along mesh-X.
|
||||||
|
//
|
||||||
|
// `getRoofSegmentBrushes`'s shed slope puts the peak at z=-d/2
|
||||||
|
// and the eave at z=+d/2 (matching the `roofHeightAtZ` helper).
|
||||||
|
// After the +π/2 rotation, shape-X=+hd → mesh-Z=-hd, so place the
|
||||||
|
// PEAK at shape-X=+hd and the EAVE at shape-X=-hd to keep the cut
|
||||||
|
// aligned with the dormer body's actual slope direction.
|
||||||
|
const shape = new THREE.Shape()
|
||||||
|
shape.moveTo(-hd, -skirt)
|
||||||
|
shape.lineTo(hd, -skirt)
|
||||||
|
shape.lineTo(hd, wallH + roofH) // peak (lands at mesh-Z = -d/2)
|
||||||
|
shape.lineTo(-hd, wallH) // eave (lands at mesh-Z = +d/2)
|
||||||
|
shape.closePath()
|
||||||
|
const geo = new THREE.ExtrudeGeometry(shape, {
|
||||||
|
depth: innerW,
|
||||||
|
bevelEnabled: false,
|
||||||
|
})
|
||||||
|
geo.rotateY(Math.PI / 2)
|
||||||
|
geo.translate(-innerW / 2, 0, 0) // centre along X
|
||||||
|
return geo
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roofType === 'hip' || roofType === 'dutch' || roofType === 'mansard') {
|
||||||
|
// Truncated pyramid: rectangular base + eave rect + a top ridge
|
||||||
|
// along the longer axis. Mirrors `getRoofSegmentBrushes`'s hip:
|
||||||
|
// run = min(w, d) / 2
|
||||||
|
// ridge length = |w - d| (zero when w == d → degenerates to a
|
||||||
|
// single apex point)
|
||||||
|
//
|
||||||
|
// For non-shed dormers, `virtualSegment.width = dormer.depth` runs
|
||||||
|
// along mesh-Z, so the longer-axis ridge direction follows the
|
||||||
|
// larger of innerD vs. innerW.
|
||||||
|
//
|
||||||
|
// Triangle windings below are CCW from outside (verified
|
||||||
|
// case-by-case via cross-product test); three-bvh-csg uses the
|
||||||
|
// normals to determine inside/outside for SUBTRACTION, so an
|
||||||
|
// inverted winding here would make the cut subtract the
|
||||||
|
// complement of the dormer footprint — a hand-built pyramid is
|
||||||
|
// the only shape in this file that does NOT get its windings from
|
||||||
|
// Three.js geometry primitives, so we have to wind it carefully.
|
||||||
|
const longerIsZ = innerD >= innerW
|
||||||
|
const ridgeHalfLen = Math.max(0, (Math.max(innerW, innerD) - Math.min(innerW, innerD)) / 2)
|
||||||
|
const peakY = wallH + roofH
|
||||||
|
|
||||||
|
// Ridge endpoints in mesh frame.
|
||||||
|
const ridgeA = longerIsZ
|
||||||
|
? ([0, peakY, -ridgeHalfLen] as const)
|
||||||
|
: ([-ridgeHalfLen, peakY, 0] as const)
|
||||||
|
const ridgeB = longerIsZ
|
||||||
|
? ([0, peakY, ridgeHalfLen] as const)
|
||||||
|
: ([ridgeHalfLen, peakY, 0] as const)
|
||||||
|
|
||||||
|
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,
|
||||||
|
// 4..7 = eave rect (y = wallH) — NW, NE, SE, SW
|
||||||
|
-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],
|
||||||
|
// 9 = ridge endpoint B (+ end along the ridge axis)
|
||||||
|
ridgeB[0], ridgeB[1], ridgeB[2],
|
||||||
|
])
|
||||||
|
|
||||||
|
// Triangles (CCW from outside). Windings verified by computing
|
||||||
|
// `(v1-v0) × (v2-v0)` for each triangle and checking the normal
|
||||||
|
// points along the expected outward direction.
|
||||||
|
const indices: number[] = [
|
||||||
|
// Bottom (normal -Y).
|
||||||
|
0, 1, 2, 0, 2, 3,
|
||||||
|
// -Z wall (normal -Z) — eave 4,5 on top, base 0,1 below.
|
||||||
|
1, 0, 4, 1, 4, 5,
|
||||||
|
// +X wall (normal +X) — eave 5,6 on top, base 1,2 below.
|
||||||
|
2, 1, 5, 2, 5, 6,
|
||||||
|
// +Z wall (normal +Z) — eave 6,7 on top, base 2,3 below.
|
||||||
|
3, 2, 6, 3, 6, 7,
|
||||||
|
// -X wall (normal -X) — eave 7,4 on top, base 3,0 below.
|
||||||
|
0, 3, 7, 0, 7, 4,
|
||||||
|
]
|
||||||
|
|
||||||
|
if (longerIsZ) {
|
||||||
|
// Ridge along Z. A=8 at -Z end, B=9 at +Z end.
|
||||||
|
// -Z end face (triangle, normal -Z/+Y): 4, 8, 5
|
||||||
|
// +X side face (quad, normal +X/+Y): 5, 9, 6 + 5, 8, 9
|
||||||
|
// +Z end face (triangle, normal +Z/+Y): 6, 9, 7
|
||||||
|
// -X side face (quad, normal -X/+Y): 7, 8, 4 + 7, 9, 8
|
||||||
|
indices.push(4, 8, 5)
|
||||||
|
indices.push(5, 9, 6, 5, 8, 9)
|
||||||
|
indices.push(6, 9, 7)
|
||||||
|
indices.push(7, 8, 4, 7, 9, 8)
|
||||||
|
} else {
|
||||||
|
// Ridge along X. A=8 at -X end, B=9 at +X end.
|
||||||
|
// -X end face (triangle, normal -X/+Y): 4, 7, 8
|
||||||
|
// -Z side face (quad, normal -Z/+Y): 4, 9, 5 + 4, 8, 9
|
||||||
|
// +X end face (triangle, normal +X/+Y): 5, 9, 6
|
||||||
|
// +Z side face (quad, normal +Z/+Y): 6, 8, 7 + 6, 9, 8
|
||||||
|
indices.push(4, 7, 8)
|
||||||
|
indices.push(4, 9, 5, 4, 8, 9)
|
||||||
|
indices.push(5, 9, 6)
|
||||||
|
indices.push(6, 8, 7, 6, 9, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
const geo = new THREE.BufferGeometry()
|
||||||
|
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.computeVertexNormals()
|
||||||
|
return geo
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roofType === 'gambrel') {
|
||||||
|
// Gambrel: two-segment slope per side. `getRoofSegmentBrushes`
|
||||||
|
// uses `run = depth / 4` and `rise = activeRh * 0.6` for the
|
||||||
|
// outer (steeper) portion. The cut profile in XY mirrors that —
|
||||||
|
// straight from eave up to a kink at (±hw/2, wallH + 0.6*roofH),
|
||||||
|
// then up to the ridge at (0, wallH + roofH). Extruded along Z.
|
||||||
|
const kinkX = hw / 2
|
||||||
|
const kinkY = wallH + roofH * 0.6
|
||||||
|
const shape = new THREE.Shape()
|
||||||
|
shape.moveTo(-hw, -skirt)
|
||||||
|
shape.lineTo(hw, -skirt)
|
||||||
|
shape.lineTo(hw, wallH)
|
||||||
|
shape.lineTo(kinkX, kinkY)
|
||||||
|
shape.lineTo(0, wallH + roofH)
|
||||||
|
shape.lineTo(-kinkX, kinkY)
|
||||||
|
shape.lineTo(-hw, wallH)
|
||||||
|
shape.closePath()
|
||||||
|
const geo = new THREE.ExtrudeGeometry(shape, {
|
||||||
|
depth: innerD,
|
||||||
|
bevelEnabled: false,
|
||||||
|
})
|
||||||
|
geo.translate(0, 0, -innerD / 2)
|
||||||
|
return geo
|
||||||
|
}
|
||||||
|
|
||||||
|
// gable (and any unrecognised type) — pentagon (rectangle +
|
||||||
|
// symmetric triangle peak), extruded along Z. Ridge runs along Z,
|
||||||
|
// matching mesh-Z which (for non-shed types) corresponds to the
|
||||||
|
// virtualSegment-X gable ridge direction after the +π/2 yaw bake
|
||||||
|
// the body geometry uses.
|
||||||
|
const shape = new THREE.Shape()
|
||||||
|
shape.moveTo(-hw, -skirt)
|
||||||
|
shape.lineTo(hw, -skirt)
|
||||||
|
shape.lineTo(hw, wallH)
|
||||||
|
shape.lineTo(0, wallH + roofH)
|
||||||
|
shape.lineTo(-hw, wallH)
|
||||||
|
shape.closePath()
|
||||||
|
const geo = new THREE.ExtrudeGeometry(shape, {
|
||||||
|
depth: innerD,
|
||||||
|
bevelEnabled: false,
|
||||||
|
})
|
||||||
|
geo.translate(0, 0, -innerD / 2)
|
||||||
|
return geo
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the segment-local cut geometry the host roof's merge loop
|
||||||
|
* subtracts from its shin / deck / wall brushes so the dormer has a
|
||||||
|
* clean hole to poke through. Mirrors `generateDormerGeometry`'s
|
||||||
|
* virtual-segment + bake: build the inner shape in
|
||||||
|
* virtual-segment-local, apply the dormer's yaw + drop-below bake,
|
||||||
|
* then the dormer's segment-local position + rotation, so the
|
||||||
|
* geometry lives in host-segment-local — the same frame the
|
||||||
|
* merged-roof CSG loop operates in.
|
||||||
|
*
|
||||||
|
* Returns null on degenerate input so the caller can skip the cut.
|
||||||
|
*
|
||||||
|
* Coordinates are SEGMENT-LOCAL. The viewer welds vertices, attaches
|
||||||
|
* a single material group, and wraps the result in a Brush — see
|
||||||
|
* `wiki/architecture/node-definitions.md` (`capabilities.roofAccessory.buildCut`).
|
||||||
|
*/
|
||||||
|
export function buildDormerRoofCut(dormer: DormerNode): THREE.BufferGeometry | null {
|
||||||
|
// Defensive: bail on any non-finite or sub-millimeter dimension. A
|
||||||
|
// degenerate cut brush passed to three-bvh-csg can produce a result
|
||||||
|
// buffer with NaN positions / invalid indices, which the WebGPU
|
||||||
|
// renderer then refuses to submit ("Invalid CommandBuffer") and the
|
||||||
|
// error cascades to every subsequent submit.
|
||||||
|
const dims = [
|
||||||
|
dormer.width,
|
||||||
|
dormer.depth,
|
||||||
|
dormer.height,
|
||||||
|
dormer.roofHeight,
|
||||||
|
dormer.wallSkirtHeight,
|
||||||
|
dormer.position[0],
|
||||||
|
dormer.position[1],
|
||||||
|
dormer.position[2],
|
||||||
|
dormer.rotation,
|
||||||
|
]
|
||||||
|
for (const v of dims) {
|
||||||
|
if (!Number.isFinite(v)) return null
|
||||||
|
}
|
||||||
|
if (dormer.width < 0.01 || dormer.depth < 0.01) return null
|
||||||
|
|
||||||
|
const skirt = dormerSkirtHeight(dormer)
|
||||||
|
const wallThickness = 0.05
|
||||||
|
const innerW = Math.max(0.05, dormer.width - 2 * wallThickness)
|
||||||
|
const innerD = Math.max(0.05, dormer.depth - 2 * wallThickness)
|
||||||
|
const wallH = Math.max(0.05, dormer.height)
|
||||||
|
const roofH = Math.max(0, dormer.roofHeight)
|
||||||
|
|
||||||
|
// Cut footprint matches the dormer's INNER cavity (outer dim minus
|
||||||
|
// the 0.05m wall thickness on each side); the dormer's own outer
|
||||||
|
// wall sits over the resulting 5cm strip of host roof, hiding it
|
||||||
|
// and preventing the sub-pixel gap an exact outer-footprint cut
|
||||||
|
// would expose where dormer wall meets host roof.
|
||||||
|
//
|
||||||
|
// The shape ABOVE the eave varies per roof type so the host hole
|
||||||
|
// matches the dormer body's outline:
|
||||||
|
// - flat: box (no peak above the eave)
|
||||||
|
// - shed: trapezoid with one sloped top edge
|
||||||
|
// - hip / dutch / mansard: pyramid (narrows on all 4 sides)
|
||||||
|
// - gable / gambrel: pentagon (narrows along width axis)
|
||||||
|
const geo = buildDormerCutShape(dormer.roofType, innerW, innerD, skirt, wallH, roofH)
|
||||||
|
|
||||||
|
// Yaw in the geometry's own (un-translated) frame so the cut aligns
|
||||||
|
// with the dormer's footprint after rotation.
|
||||||
|
if (Math.abs(dormer.rotation) > 1e-4) {
|
||||||
|
geo.rotateY(dormer.rotation)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Translate into segment-local. position[1] becomes the dormer's
|
||||||
|
// local Y = 0 (the wall foot / eave line); the shape's foot at
|
||||||
|
// local Y = -skirt then sits at world Y = position[1] - skirt.
|
||||||
|
geo.translate(dormer.position[0], dormer.position[1], dormer.position[2])
|
||||||
|
|
||||||
|
// The viewer's merge loop welds vertices, attaches a single material
|
||||||
|
// group, and wraps in a Brush before subtracting from the host
|
||||||
|
// segment's shin / deck / wall. Kinds only emit the raw shape.
|
||||||
|
return geo
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reassign slot-0 (wall) triangles whose entire footprint sits above
|
||||||
|
* `wallHeight` to a separate material slot — lets the renderer colour
|
||||||
|
* the rectangular wall and the gable triangle differently.
|
||||||
|
*/
|
||||||
|
function splitDormerGableMaterial(
|
||||||
|
geometry: THREE.BufferGeometry,
|
||||||
|
wallHeight: number,
|
||||||
|
gableMatIndex: number,
|
||||||
|
): void {
|
||||||
|
const position = geometry.getAttribute('position') as THREE.BufferAttribute | undefined
|
||||||
|
const index = geometry.getIndex()
|
||||||
|
if (!(position && index) || index.count === 0 || geometry.groups.length === 0) return
|
||||||
|
|
||||||
|
const triangleCount = index.count / 3
|
||||||
|
if (triangleCount === 0) return
|
||||||
|
|
||||||
|
const triangleMats = new Array<number>(triangleCount).fill(0)
|
||||||
|
for (const g of geometry.groups) {
|
||||||
|
const startTri = Math.floor(g.start / 3)
|
||||||
|
const endTri = Math.floor((g.start + g.count) / 3)
|
||||||
|
const mat = g.materialIndex ?? 0
|
||||||
|
for (let i = startTri; i < endTri; i++) triangleMats[i] = mat
|
||||||
|
}
|
||||||
|
|
||||||
|
const epsilon = 0.001
|
||||||
|
for (let i = 0; i < triangleCount; i++) {
|
||||||
|
if (triangleMats[i] !== 0) continue
|
||||||
|
const a = index.getX(i * 3)
|
||||||
|
const b = index.getX(i * 3 + 1)
|
||||||
|
const c = index.getX(i * 3 + 2)
|
||||||
|
const ya = position.getY(a)
|
||||||
|
const yb = position.getY(b)
|
||||||
|
const yc = position.getY(c)
|
||||||
|
if (ya > wallHeight + epsilon && yb > wallHeight + epsilon && yc > wallHeight + epsilon) {
|
||||||
|
triangleMats[i] = gableMatIndex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedTri = Array.from({ length: triangleCount }, (_, i) => i)
|
||||||
|
sortedTri.sort((a, b) => (triangleMats[a] ?? 0) - (triangleMats[b] ?? 0))
|
||||||
|
|
||||||
|
const newIdx = new Uint32Array(index.count)
|
||||||
|
for (let i = 0; i < sortedTri.length; i++) {
|
||||||
|
const ti = sortedTri[i] as number
|
||||||
|
newIdx[i * 3] = index.getX(ti * 3)
|
||||||
|
newIdx[i * 3 + 1] = index.getX(ti * 3 + 1)
|
||||||
|
newIdx[i * 3 + 2] = index.getX(ti * 3 + 2)
|
||||||
|
}
|
||||||
|
geometry.setIndex(new THREE.BufferAttribute(newIdx, 1))
|
||||||
|
|
||||||
|
geometry.clearGroups()
|
||||||
|
let groupStart = 0
|
||||||
|
let curMat = triangleMats[sortedTri[0] as number] as number
|
||||||
|
for (let i = 1; i < sortedTri.length; i++) {
|
||||||
|
const mat = triangleMats[sortedTri[i] as number] as number
|
||||||
|
if (mat !== curMat) {
|
||||||
|
geometry.addGroup(groupStart, i * 3 - groupStart, curMat)
|
||||||
|
groupStart = i * 3
|
||||||
|
curMat = mat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
geometry.addGroup(groupStart, sortedTri.length * 3 - groupStart, curMat)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
|
||||||
|
const uv = geometry.getAttribute('uv')
|
||||||
|
if (!uv) return
|
||||||
|
geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(uv.array), 2))
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import {
|
||||||
|
type AnyNode,
|
||||||
|
type DormerNode as DormerNodeType,
|
||||||
|
type NodeDefinition,
|
||||||
|
DormerNode as DormerNodeSchema,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import { buildDormerRoofCut } from './csg-geometry'
|
||||||
|
import { dormerPaint } from './paint'
|
||||||
|
import { dormerParametrics } from './parametrics'
|
||||||
|
import { DormerNode } from './schema'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dormer — a small house-shaped protrusion sitting on top of a roof
|
||||||
|
* segment. The window opening is inlined into the dormer's schema
|
||||||
|
* (window* fields drive parametric geometry on the front face), not
|
||||||
|
* a hosted child node — so `relations.hosts` stays unset.
|
||||||
|
*
|
||||||
|
* **Scope of this port — stub.** Schema is complete (every field from
|
||||||
|
* the archive, including the four per-surface material slots and the
|
||||||
|
* full window-opening field set). Geometry renders a simple house
|
||||||
|
* silhouette (box body + triangular gable roof) for all `roofType`
|
||||||
|
* variants — the archive's variant-specific dormer roof shapes,
|
||||||
|
* window opening + frame, sill, and the CSG trim where the dormer
|
||||||
|
* meets the host roof are deferred. Per-surface paints (`topMaterial`,
|
||||||
|
* `sideMaterial`, `wallMaterial`) resolve via the shared helper from
|
||||||
|
* core but only roof / wall surfaces are emitted by the stub geometry.
|
||||||
|
*/
|
||||||
|
export const dormerDefinition: NodeDefinition<typeof DormerNode> = {
|
||||||
|
kind: 'dormer',
|
||||||
|
schemaVersion: 1,
|
||||||
|
schema: DormerNode,
|
||||||
|
category: 'structure',
|
||||||
|
|
||||||
|
defaults: () => {
|
||||||
|
// Zod fills in id/type via their .default() factories; we strip
|
||||||
|
// both so the returned shape is a partial template a consumer can
|
||||||
|
// spread into createNode() with a fresh id.
|
||||||
|
const stub = DormerNodeSchema.parse({})
|
||||||
|
const { id: _id, type: _type, ...rest } = stub
|
||||||
|
return rest
|
||||||
|
},
|
||||||
|
|
||||||
|
capabilities: {
|
||||||
|
selectable: { hitVolume: 'bbox' },
|
||||||
|
duplicable: true,
|
||||||
|
deletable: true,
|
||||||
|
// Mounts on a roof segment via `roofSegmentId`. Dirty marks
|
||||||
|
// cascade to the host segment's parent roof so its merged shell
|
||||||
|
// re-CSGs with the new cut. `buildCut` returns the segment-local
|
||||||
|
// geometry the merge loop subtracts from shin / deck / wall.
|
||||||
|
roofAccessory: {
|
||||||
|
buildCut: (node: AnyNode, _hostSegment: AnyNode) =>
|
||||||
|
buildDormerRoofCut(node as DormerNodeType),
|
||||||
|
},
|
||||||
|
// Paint dispatch for the wall / side / top surface split. The
|
||||||
|
// editor's selection-manager routes paint hover / click /
|
||||||
|
// preview through this entry rather than carrying a kind-name
|
||||||
|
// arm.
|
||||||
|
paint: dormerPaint,
|
||||||
|
},
|
||||||
|
|
||||||
|
affordanceTools: {
|
||||||
|
// Drag-to-place tool for duplicate + move. Reuses the placement
|
||||||
|
// ghost preview but seeds it from the moving (cloned) node so the
|
||||||
|
// duplicate keeps the source's dimensions, materials, and window
|
||||||
|
// options.
|
||||||
|
move: () => import('./move-tool'),
|
||||||
|
},
|
||||||
|
|
||||||
|
parametrics: dormerParametrics,
|
||||||
|
|
||||||
|
renderer: {
|
||||||
|
kind: 'parametric',
|
||||||
|
module: () => import('./renderer'),
|
||||||
|
},
|
||||||
|
|
||||||
|
tool: () => import('./tool'),
|
||||||
|
toolHints: [
|
||||||
|
{ key: 'Left click', label: 'Place dormer on roof' },
|
||||||
|
{ key: 'R / Shift+R', label: 'Rotate ghost ±15°' },
|
||||||
|
{ key: 'Esc', label: 'Cancel' },
|
||||||
|
],
|
||||||
|
|
||||||
|
presentation: {
|
||||||
|
label: 'Dormer',
|
||||||
|
description: 'House-shaped protrusion on a roof segment.',
|
||||||
|
icon: { kind: 'url', src: '/icons/roof.png' },
|
||||||
|
paletteSection: 'structure',
|
||||||
|
paletteOrder: 125,
|
||||||
|
},
|
||||||
|
|
||||||
|
mcp: {
|
||||||
|
description:
|
||||||
|
'A dormer on a roof segment. Box body + gable roof + inlined window opening. Geometry beyond the stub silhouette coming later.',
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { DormerNode } from '@pascal-app/core'
|
||||||
|
import * as THREE from 'three'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grid-snap step (metres) applied to the world cursor position while a
|
||||||
|
* dormer placement / move ghost is in flight. Shared by both tools so
|
||||||
|
* the audible snap and the committed position step stay in lockstep.
|
||||||
|
*/
|
||||||
|
export const DORMER_PLACEMENT_SNAP_M = 0.05
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotation step (radians) used by the keyboard rotate shortcuts (R /
|
||||||
|
* Shift+R) while a dormer placement / move ghost is in flight. 15° —
|
||||||
|
* lets the user reach the 90° cardinals in six taps and the 45°
|
||||||
|
* diagonals in three.
|
||||||
|
*/
|
||||||
|
export const DORMER_PLACEMENT_ROTATION_STEP = (15 * Math.PI) / 180
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight silhouette geometry used by the placement / move-tool
|
||||||
|
* ghost preview only. Renders the dormer as an extruded pentagon
|
||||||
|
* (rectangle body + triangular gable) dropped by `wallSkirtHeight` below
|
||||||
|
* the anchor so the cursor sits at the floor of the dormer the way the
|
||||||
|
* committed CSG geometry does.
|
||||||
|
*
|
||||||
|
* For `roofType === 'flat'` (or `roofHeight === 0`) the gable apex is
|
||||||
|
* skipped and the shape collapses to a rectangle. Other roof types use
|
||||||
|
* the gable approximation — exact per-type silhouettes are a future
|
||||||
|
* improvement.
|
||||||
|
*
|
||||||
|
* Kept self-contained (no `@pascal-app/viewer` imports) so the geometry
|
||||||
|
* test doesn't drag in the CSG / BVH module graph, which fails to load
|
||||||
|
* outside of a browser/WebGL context. The viewer has its own
|
||||||
|
* `buildDormerFallbackGeometry` that mirrors this shape — used both as
|
||||||
|
* the CSG fallback when boolean ops fail and as the live-drag preview
|
||||||
|
* in the dormer renderer.
|
||||||
|
*/
|
||||||
|
export function buildDormerGhostGeometry(node: DormerNode): THREE.BufferGeometry {
|
||||||
|
const w = Math.max(0.05, node.width)
|
||||||
|
const wallH = Math.max(0.05, node.height)
|
||||||
|
const roofH = Math.max(0, node.roofHeight)
|
||||||
|
const d = Math.max(0.05, node.depth)
|
||||||
|
const skirt = Math.max(0.05, node.wallSkirtHeight)
|
||||||
|
const hw = w / 2
|
||||||
|
const isFlat = node.roofType === 'flat' || roofH === 0
|
||||||
|
|
||||||
|
const shape = new THREE.Shape()
|
||||||
|
shape.moveTo(-hw, -skirt)
|
||||||
|
shape.lineTo(hw, -skirt)
|
||||||
|
shape.lineTo(hw, wallH)
|
||||||
|
if (!isFlat) shape.lineTo(0, wallH + roofH)
|
||||||
|
shape.lineTo(-hw, wallH)
|
||||||
|
shape.closePath()
|
||||||
|
|
||||||
|
const geo = new THREE.ExtrudeGeometry(shape, { depth: d, bevelEnabled: false })
|
||||||
|
geo.translate(0, 0, -d / 2)
|
||||||
|
return geo
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inspector helper: which window-shape sub-controls to surface for the
|
||||||
|
* current dormer.
|
||||||
|
*/
|
||||||
|
export function dormerSupportsArch(node: DormerNode): boolean {
|
||||||
|
return node.windowShape === 'arch'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dormerSupportsCornerRadii(node: DormerNode): boolean {
|
||||||
|
return node.windowShape === 'rounded'
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export { dormerDefinition } from './definition'
|
||||||
|
export {
|
||||||
|
buildDormerGhostGeometry,
|
||||||
|
dormerSupportsArch,
|
||||||
|
dormerSupportsCornerRadii,
|
||||||
|
} from './geometry'
|
||||||
|
export { DormerNode, getEffectiveDormerSurfaceMaterial } from './schema'
|
||||||
|
export type {
|
||||||
|
DormerSurfaceMaterialRole,
|
||||||
|
DormerSurfaceMaterialSpec,
|
||||||
|
} from './schema'
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import {
|
||||||
|
type AnyNodeId,
|
||||||
|
type DormerNode,
|
||||||
|
DormerNode as DormerNodeSchema,
|
||||||
|
type RoofSegmentNode,
|
||||||
|
sceneRegistry,
|
||||||
|
useScene,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import { useEditor } from '@pascal-app/editor'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { useEffect, useMemo } from 'react'
|
||||||
|
import DormerPreview from './preview'
|
||||||
|
import { useDormerPlacement } from './use-dormer-placement'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drag-to-place tool for dormer duplicate / move. Receives the moving
|
||||||
|
* node (a clone with `id` stripped + `metadata.isNew = true` after a
|
||||||
|
* Duplicate action) via `node` prop, shows the same ghost preview as
|
||||||
|
* placement, and on click commits the cloned dormer to the hit segment.
|
||||||
|
*
|
||||||
|
* On cancel, a duplicate clone is deleted and an existing dormer is
|
||||||
|
* restored to its original segment + position. Mounted via
|
||||||
|
* `def.affordanceTools.move`.
|
||||||
|
*/
|
||||||
|
const MoveDormerTool = ({ node }: { node: DormerNode }) => {
|
||||||
|
const setSelection = useViewer((s) => s.setSelection)
|
||||||
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||||
|
|
||||||
|
// Ghost data — same as the moving clone but pinned to position[0,0,0]
|
||||||
|
// (the outer groups place it on the roof). Reparse so Zod fills any
|
||||||
|
// defaults missing from the clone.
|
||||||
|
const previewNode = useMemo(() => {
|
||||||
|
const { id: _id, ...rest } = node
|
||||||
|
return DormerNodeSchema.parse({
|
||||||
|
...rest,
|
||||||
|
position: [0, 0, 0],
|
||||||
|
rotation: 0,
|
||||||
|
})
|
||||||
|
}, [node])
|
||||||
|
|
||||||
|
// Hide the moving dormer while dragging. Restored in cleanup or on
|
||||||
|
// commit. We also mark metadata.isTransient so any other consumer
|
||||||
|
// (e.g. the inspector) can short-circuit.
|
||||||
|
const meta =
|
||||||
|
typeof node.metadata === 'object' && node.metadata !== null
|
||||||
|
? (node.metadata as Record<string, unknown>)
|
||||||
|
: {}
|
||||||
|
const isNew = !!meta.isNew
|
||||||
|
const originalRotation = node.rotation ?? 0
|
||||||
|
const originalMetadata = node.metadata
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isNew) {
|
||||||
|
useScene.getState().updateNode(node.id as AnyNodeId, {
|
||||||
|
metadata: { ...meta, isTransient: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const dormerObj = sceneRegistry.nodes.get(node.id)
|
||||||
|
const prevVisible = dormerObj?.visible
|
||||||
|
if (dormerObj) dormerObj.visible = false
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Restore visibility + metadata if the move was cancelled.
|
||||||
|
const obj = sceneRegistry.nodes.get(node.id)
|
||||||
|
if (obj) obj.visible = prevVisible ?? true
|
||||||
|
if (!isNew) {
|
||||||
|
useScene.getState().updateNode(node.id as AnyNodeId, {
|
||||||
|
metadata: originalMetadata,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: capture-on-mount; meta is intentionally not re-read on changes.
|
||||||
|
}, [node.id, isNew])
|
||||||
|
|
||||||
|
const { activeBuildingId, segmentXform, hitLocal, ghostRotation } = useDormerPlacement({
|
||||||
|
initialRotation: originalRotation,
|
||||||
|
onCommit: (hit, rotation) => {
|
||||||
|
const state = useScene.getState()
|
||||||
|
|
||||||
|
// Strip the `isNew` / `isTransient` flags — only used to mark a
|
||||||
|
// clone or in-flight move that hasn't been committed yet.
|
||||||
|
const cleanedMeta = (() => {
|
||||||
|
const m =
|
||||||
|
node.metadata && typeof node.metadata === 'object' && !Array.isArray(node.metadata)
|
||||||
|
? (node.metadata as Record<string, unknown>)
|
||||||
|
: {}
|
||||||
|
const { isNew: _isNew, isTransient: _isTransient, ...rest } = m as {
|
||||||
|
isNew?: boolean
|
||||||
|
isTransient?: boolean
|
||||||
|
}
|
||||||
|
return Object.keys(rest).length > 0 ? rest : undefined
|
||||||
|
})()
|
||||||
|
|
||||||
|
if (isNew || !node.id) {
|
||||||
|
const { id: _id, ...rest } = node
|
||||||
|
const committed = DormerNodeSchema.parse({
|
||||||
|
...rest,
|
||||||
|
roofSegmentId: hit.segment.id,
|
||||||
|
parentId: hit.segment.id,
|
||||||
|
position: [hit.localX, hit.localY, hit.localZ],
|
||||||
|
rotation,
|
||||||
|
metadata: cleanedMeta,
|
||||||
|
})
|
||||||
|
state.createNode(committed, hit.segment.id as AnyNodeId)
|
||||||
|
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
||||||
|
setSelection({ selectedIds: [committed.id] })
|
||||||
|
} else {
|
||||||
|
const prevSegmentId = node.roofSegmentId as AnyNodeId | undefined
|
||||||
|
state.updateNode(node.id as AnyNodeId, {
|
||||||
|
roofSegmentId: hit.segment.id,
|
||||||
|
parentId: hit.segment.id,
|
||||||
|
position: [hit.localX, hit.localY, hit.localZ],
|
||||||
|
rotation,
|
||||||
|
metadata: cleanedMeta,
|
||||||
|
})
|
||||||
|
if (prevSegmentId) state.dirtyNodes.add(prevSegmentId)
|
||||||
|
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
||||||
|
// Unlist from previous segment's children and add to the new one.
|
||||||
|
if (prevSegmentId && prevSegmentId !== (hit.segment.id as AnyNodeId)) {
|
||||||
|
const prevSeg = state.nodes[prevSegmentId] as RoofSegmentNode | undefined
|
||||||
|
if (prevSeg) {
|
||||||
|
state.updateNode(prevSegmentId, {
|
||||||
|
children: (prevSeg.children ?? []).filter((id) => id !== node.id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
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],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSelection({ selectedIds: [node.id] })
|
||||||
|
}
|
||||||
|
const dormerObj = sceneRegistry.nodes.get(node.id)
|
||||||
|
if (dormerObj) dormerObj.visible = true
|
||||||
|
setMovingNode(null)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!activeBuildingId || !segmentXform || !hitLocal) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group position={segmentXform.position} quaternion={segmentXform.quaternion}>
|
||||||
|
<group position={hitLocal}>
|
||||||
|
<group rotation-y={ghostRotation}>
|
||||||
|
<DormerPreview node={previewNode} />
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MoveDormerTool
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import type {
|
||||||
|
DormerNode,
|
||||||
|
DormerSurfaceMaterialRole,
|
||||||
|
MaterialSchema,
|
||||||
|
PaintCapability,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import { getEffectiveDormerSurfaceMaterial } from '@pascal-app/core'
|
||||||
|
import { createMaterial, createMaterialFromPresetRef } from '@pascal-app/viewer'
|
||||||
|
import type { Material, Mesh } from 'three'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a dormer face click to its logical surface role.
|
||||||
|
*
|
||||||
|
* The dormer body mesh has 5 material slots (see
|
||||||
|
* `csg-geometry.ts:generateDormerGeometry`):
|
||||||
|
* 0 = wall (rectangular wall)
|
||||||
|
* 1 = side (deck along the slope)
|
||||||
|
* 2 = interior — paint as wall
|
||||||
|
* 3 = top (roof shingle)
|
||||||
|
* 4 = gable triangle — paint as wall
|
||||||
|
*
|
||||||
|
* Window-frame meshes are not in the body mesh; they're routed to
|
||||||
|
* 'side' separately by the editor when the click lands on them.
|
||||||
|
*/
|
||||||
|
export function resolveDormerRole(materialIndex: number | null): DormerSurfaceMaterialRole {
|
||||||
|
if (materialIndex === 3) return 'top'
|
||||||
|
if (materialIndex === 1) return 'side'
|
||||||
|
return 'wall'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDormerMaterialPatch(
|
||||||
|
role: DormerSurfaceMaterialRole,
|
||||||
|
material: MaterialSchema | undefined,
|
||||||
|
materialPreset: string | undefined,
|
||||||
|
): Partial<DormerNode> {
|
||||||
|
if (role === 'top') {
|
||||||
|
return { topMaterial: material, topMaterialPreset: materialPreset }
|
||||||
|
}
|
||||||
|
if (role === 'side') {
|
||||||
|
return { sideMaterial: material, sideMaterialPreset: materialPreset }
|
||||||
|
}
|
||||||
|
return { wallMaterial: material, wallMaterialPreset: materialPreset }
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPreviewMaterial(
|
||||||
|
material: MaterialSchema | undefined,
|
||||||
|
materialPreset: string | undefined,
|
||||||
|
): Material | null {
|
||||||
|
if (materialPreset) {
|
||||||
|
return createMaterialFromPresetRef(materialPreset)
|
||||||
|
}
|
||||||
|
if (material) {
|
||||||
|
return createMaterial(material)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a preview material to the dormer's `dormer-body` mesh at the
|
||||||
|
* slots that map to `role`:
|
||||||
|
* role='wall' → slots [0, 2, 4]
|
||||||
|
* role='side' → slot [1]
|
||||||
|
* role='top' → slot [3]
|
||||||
|
*/
|
||||||
|
function applyDormerPreview(
|
||||||
|
role: DormerSurfaceMaterialRole,
|
||||||
|
previewMaterial: Material,
|
||||||
|
root: import('three').Object3D,
|
||||||
|
): (() => void) | null {
|
||||||
|
const slotsToPaint = (() => {
|
||||||
|
if (role === 'top') return [3]
|
||||||
|
if (role === 'side') return [1]
|
||||||
|
return [0, 2, 4]
|
||||||
|
})()
|
||||||
|
|
||||||
|
const restores: Array<() => void> = []
|
||||||
|
root.traverse((object) => {
|
||||||
|
const mesh = object as Mesh
|
||||||
|
if (!mesh.isMesh) return
|
||||||
|
if (mesh.name !== 'dormer-body') return
|
||||||
|
const current = mesh.material as Material | Material[]
|
||||||
|
if (!Array.isArray(current)) return
|
||||||
|
const previousArray = [...current]
|
||||||
|
const nextArray = [...current]
|
||||||
|
for (const idx of slotsToPaint) {
|
||||||
|
if (current[idx]) nextArray[idx] = previewMaterial
|
||||||
|
}
|
||||||
|
mesh.material = nextArray
|
||||||
|
restores.push(() => {
|
||||||
|
mesh.material = previousArray
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if (restores.length === 0) return null
|
||||||
|
return () => {
|
||||||
|
for (let i = restores.length - 1; i >= 0; i -= 1) restores[i]?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capability binding for the dormer kind. The editor's
|
||||||
|
* selection-manager invokes these in place of the legacy
|
||||||
|
* `if (node.type === 'dormer') { ... }` arm.
|
||||||
|
*/
|
||||||
|
export const dormerPaint: PaintCapability = {
|
||||||
|
resolveRole: ({ materialIndex }) => resolveDormerRole(materialIndex),
|
||||||
|
buildPatch: ({ role, material, materialPreset }) =>
|
||||||
|
buildDormerMaterialPatch(role as DormerSurfaceMaterialRole, material, materialPreset),
|
||||||
|
applyPreview: ({ role, material, materialPreset, root }) => {
|
||||||
|
const previewMaterial = buildPreviewMaterial(material, materialPreset)
|
||||||
|
if (!previewMaterial) return null
|
||||||
|
return applyDormerPreview(role as DormerSurfaceMaterialRole, previewMaterial, root)
|
||||||
|
},
|
||||||
|
getEffectiveMaterial: ({ node, role }) => {
|
||||||
|
const spec = getEffectiveDormerSurfaceMaterial(
|
||||||
|
node as DormerNode,
|
||||||
|
role as DormerSurfaceMaterialRole,
|
||||||
|
)
|
||||||
|
return { material: spec.material, materialPreset: spec.materialPreset }
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { ActionButton, ActionGroup, PanelSection } from '@pascal-app/editor'
|
||||||
|
import { Copy, Move, Trash2 } from 'lucide-react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move / Duplicate / Delete buttons at the bottom of the dormer
|
||||||
|
* inspector. Pure presentation — owners pass the three handlers.
|
||||||
|
*/
|
||||||
|
export function DormerActionsSection({
|
||||||
|
onMove,
|
||||||
|
onDuplicate,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
onMove: () => void
|
||||||
|
onDuplicate: () => void
|
||||||
|
onDelete: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PanelSection title="Actions">
|
||||||
|
<ActionGroup>
|
||||||
|
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={onMove} />
|
||||||
|
<ActionButton
|
||||||
|
icon={<Copy className="h-3.5 w-3.5" />}
|
||||||
|
label="Duplicate"
|
||||||
|
onClick={onDuplicate}
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
className="hover:bg-red-500/20"
|
||||||
|
icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
|
||||||
|
label="Delete"
|
||||||
|
onClick={onDelete}
|
||||||
|
/>
|
||||||
|
</ActionGroup>
|
||||||
|
</PanelSection>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import {
|
||||||
|
type AnyNodeId,
|
||||||
|
type DormerNode,
|
||||||
|
type RoofNode,
|
||||||
|
type RoofSegmentNode,
|
||||||
|
sceneRegistry,
|
||||||
|
useScene,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import { PanelSection, SliderControl } from '@pascal-app/editor'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { Vector3 } from 'three'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Position section: X, Z (world-space) and Rotation (world-space)
|
||||||
|
* sliders. Owns the read side of the dormer's world transform —
|
||||||
|
* confined to a single useMemo so the underlying `updateWorldMatrix`
|
||||||
|
* calls don't fire on unrelated re-renders. Also owns the segment-
|
||||||
|
* local commits, including the cross-segment reparent case.
|
||||||
|
*/
|
||||||
|
export function DormerPositionSection({
|
||||||
|
node,
|
||||||
|
segment,
|
||||||
|
roof,
|
||||||
|
selectedId,
|
||||||
|
previewProp,
|
||||||
|
commitProp,
|
||||||
|
}: {
|
||||||
|
node: DormerNode
|
||||||
|
segment: RoofSegmentNode | undefined
|
||||||
|
roof: RoofNode | undefined
|
||||||
|
selectedId: string
|
||||||
|
previewProp: (updates: Partial<DormerNode>) => void
|
||||||
|
commitProp: (updates: Partial<DormerNode>) => void
|
||||||
|
}) {
|
||||||
|
const px = node.position[0]
|
||||||
|
const py = node.position[1]
|
||||||
|
const pz = node.position[2]
|
||||||
|
const nodeRotation = node.rotation
|
||||||
|
const segmentId = segment?.id
|
||||||
|
const roofChildrenKey = (roof?.children ?? []).join(',')
|
||||||
|
|
||||||
|
const worldXform = useMemo(() => {
|
||||||
|
const dormerObj = sceneRegistry.nodes.get(selectedId)
|
||||||
|
let worldX = 0
|
||||||
|
let worldZ = 0
|
||||||
|
let worldRotation = nodeRotation ?? 0
|
||||||
|
if (dormerObj) {
|
||||||
|
dormerObj.updateWorldMatrix(true, false)
|
||||||
|
const localPt = new Vector3(px ?? 0, 0, pz ?? 0)
|
||||||
|
const worldPt = localPt.applyMatrix4(dormerObj.matrixWorld)
|
||||||
|
worldX = worldPt.x
|
||||||
|
worldZ = worldPt.z
|
||||||
|
const m = dormerObj.matrixWorld.elements
|
||||||
|
worldRotation = Math.atan2(-(m[2] ?? 0), m[0] ?? 1) + (nodeRotation ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
let bounds: { minX: number; maxX: number; minZ: number; maxZ: number } | null = null
|
||||||
|
if (roof) {
|
||||||
|
const state = useScene.getState()
|
||||||
|
let lo_x = Number.POSITIVE_INFINITY
|
||||||
|
let hi_x = Number.NEGATIVE_INFINITY
|
||||||
|
let lo_z = Number.POSITIVE_INFINITY
|
||||||
|
let hi_z = Number.NEGATIVE_INFINITY
|
||||||
|
for (const childId of roof.children ?? []) {
|
||||||
|
const seg = state.nodes[childId as AnyNodeId] as RoofSegmentNode | undefined
|
||||||
|
if (!seg) continue
|
||||||
|
const segObj = sceneRegistry.nodes.get(seg.id)
|
||||||
|
if (!segObj) continue
|
||||||
|
segObj.updateWorldMatrix(true, false)
|
||||||
|
const segWorldCenter = new Vector3().applyMatrix4(segObj.matrixWorld)
|
||||||
|
const r = Math.hypot(seg.width, seg.depth) / 2
|
||||||
|
lo_x = Math.min(lo_x, segWorldCenter.x - r)
|
||||||
|
hi_x = Math.max(hi_x, segWorldCenter.x + r)
|
||||||
|
lo_z = Math.min(lo_z, segWorldCenter.z - r)
|
||||||
|
hi_z = Math.max(hi_z, segWorldCenter.z + r)
|
||||||
|
}
|
||||||
|
if (Number.isFinite(lo_x)) bounds = { minX: lo_x, maxX: hi_x, minZ: lo_z, maxZ: hi_z }
|
||||||
|
}
|
||||||
|
return { worldX, worldZ, worldRotation, bounds }
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: roofChildrenKey is the stable signature of `roof.children`; intentionally omitting `roof` (object identity) in favor of the joined ids.
|
||||||
|
}, [selectedId, px, py, pz, nodeRotation, segmentId, roofChildrenKey])
|
||||||
|
|
||||||
|
const worldX_now = worldXform.worldX
|
||||||
|
const worldZ_now = worldXform.worldZ
|
||||||
|
const worldRotation_now = worldXform.worldRotation
|
||||||
|
const worldMinX = worldXform.bounds?.minX ?? worldX_now - 20
|
||||||
|
const worldMaxX = worldXform.bounds?.maxX ?? worldX_now + 20
|
||||||
|
const worldMinZ = worldXform.bounds?.minZ ?? worldZ_now - 20
|
||||||
|
const worldMaxZ = worldXform.bounds?.maxZ ?? worldZ_now + 20
|
||||||
|
|
||||||
|
const findSegmentForWorldPoint = (
|
||||||
|
wx: number,
|
||||||
|
wz: number,
|
||||||
|
): { segment: RoofSegmentNode; localX: number; localZ: number } | null => {
|
||||||
|
const state = useScene.getState()
|
||||||
|
const worldPt = new Vector3(wx, 0, wz)
|
||||||
|
for (const candidate of Object.values(state.nodes)) {
|
||||||
|
if (!candidate || candidate.type !== 'roof-segment') continue
|
||||||
|
const seg = candidate as RoofSegmentNode
|
||||||
|
const segObj = sceneRegistry.nodes.get(seg.id)
|
||||||
|
if (!segObj) continue
|
||||||
|
segObj.updateWorldMatrix(true, false)
|
||||||
|
const local = segObj.worldToLocal(worldPt.clone())
|
||||||
|
if (Math.abs(local.x) <= seg.width / 2 && Math.abs(local.z) <= seg.depth / 2) {
|
||||||
|
return { segment: seg, localX: local.x, localZ: local.z }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const worldToSegLocal = (
|
||||||
|
wx: number,
|
||||||
|
wz: number,
|
||||||
|
seg: RoofSegmentNode,
|
||||||
|
): { localX: number; localZ: number } => {
|
||||||
|
const segObj = sceneRegistry.nodes.get(seg.id)
|
||||||
|
if (!segObj) return { localX: wx, localZ: wz }
|
||||||
|
segObj.updateWorldMatrix(true, false)
|
||||||
|
const local = segObj.worldToLocal(new Vector3(wx, 0, wz))
|
||||||
|
return { localX: local.x, localZ: local.z }
|
||||||
|
}
|
||||||
|
|
||||||
|
const commitWorldPosition = (newWorldX: number, newWorldZ: number) => {
|
||||||
|
if (!segment) return
|
||||||
|
const oldWorldRotation = worldRotation_now
|
||||||
|
const target = findSegmentForWorldPoint(newWorldX, newWorldZ)
|
||||||
|
if (target && target.segment.id !== segment.id) {
|
||||||
|
// Moving to a different segment — segment-local Y is meaningless
|
||||||
|
// across the change, so reset to 0 and let the new segment's
|
||||||
|
// slope-anchored geometry take over.
|
||||||
|
const newSegObj = sceneRegistry.nodes.get(target.segment.id)
|
||||||
|
let newAncestorWorldY = 0
|
||||||
|
if (newSegObj) {
|
||||||
|
newSegObj.updateWorldMatrix(true, false)
|
||||||
|
const m = newSegObj.matrixWorld.elements
|
||||||
|
newAncestorWorldY = Math.atan2(-(m[2] ?? 0), m[0] ?? 1)
|
||||||
|
}
|
||||||
|
const newSegLocalRot = oldWorldRotation - newAncestorWorldY
|
||||||
|
commitProp({
|
||||||
|
roofSegmentId: target.segment.id,
|
||||||
|
parentId: target.segment.id,
|
||||||
|
position: [target.localX, 0, target.localZ],
|
||||||
|
rotation: newSegLocalRot,
|
||||||
|
} as Partial<DormerNode>)
|
||||||
|
} else {
|
||||||
|
// Same segment — preserve the existing Y so the dormer doesn't
|
||||||
|
// snap back to the segment foot when the user only adjusts X/Z.
|
||||||
|
const local = worldToSegLocal(newWorldX, newWorldZ, segment)
|
||||||
|
commitProp({ position: [local.localX, py ?? 0, local.localZ] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const commitWorldRotation = (newWorldRot: number) => {
|
||||||
|
if (!segment) return
|
||||||
|
let ancestorWorldY = 0
|
||||||
|
const segObj = sceneRegistry.nodes.get(segment.id)
|
||||||
|
if (segObj) {
|
||||||
|
segObj.updateWorldMatrix(true, false)
|
||||||
|
const m = segObj.matrixWorld.elements
|
||||||
|
ancestorWorldY = Math.atan2(-(m[2] ?? 0), m[0] ?? 1)
|
||||||
|
}
|
||||||
|
commitProp({ rotation: newWorldRot - ancestorWorldY })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PanelSection title="Position">
|
||||||
|
<SliderControl
|
||||||
|
label="X"
|
||||||
|
max={Math.round(worldMaxX * 10) / 10}
|
||||||
|
min={Math.round(worldMinX * 10) / 10}
|
||||||
|
onChange={(newWorldX) => {
|
||||||
|
if (!segment) return
|
||||||
|
const local = worldToSegLocal(newWorldX, worldZ_now, segment)
|
||||||
|
previewProp({ position: [local.localX, py ?? 0, local.localZ] })
|
||||||
|
}}
|
||||||
|
onCommit={(newWorldX) => commitWorldPosition(newWorldX, worldZ_now)}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.05}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round(worldX_now * 100) / 100}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Z"
|
||||||
|
max={Math.round(worldMaxZ * 10) / 10}
|
||||||
|
min={Math.round(worldMinZ * 10) / 10}
|
||||||
|
onChange={(newWorldZ) => {
|
||||||
|
if (!segment) return
|
||||||
|
const local = worldToSegLocal(worldX_now, newWorldZ, segment)
|
||||||
|
previewProp({ position: [local.localX, py ?? 0, local.localZ] })
|
||||||
|
}}
|
||||||
|
onCommit={(newWorldZ) => commitWorldPosition(worldX_now, newWorldZ)}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.05}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round(worldZ_now * 100) / 100}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Rotation"
|
||||||
|
max={180}
|
||||||
|
min={-180}
|
||||||
|
onChange={(degrees) => {
|
||||||
|
const newWorldRot = (degrees * Math.PI) / 180
|
||||||
|
let ancestorWorldY = 0
|
||||||
|
if (segment) {
|
||||||
|
const segObj = sceneRegistry.nodes.get(segment.id)
|
||||||
|
if (segObj) {
|
||||||
|
segObj.updateWorldMatrix(true, false)
|
||||||
|
const m = segObj.matrixWorld.elements
|
||||||
|
ancestorWorldY = Math.atan2(-(m[2] ?? 0), m[0] ?? 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
previewProp({ rotation: newWorldRot - ancestorWorldY })
|
||||||
|
}}
|
||||||
|
onCommit={(degrees) => commitWorldRotation((degrees * Math.PI) / 180)}
|
||||||
|
precision={0}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={1}
|
||||||
|
unit="°"
|
||||||
|
value={Math.round((worldRotation_now * 180) / Math.PI)}
|
||||||
|
/>
|
||||||
|
</PanelSection>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { DormerNode } from '@pascal-app/core'
|
||||||
|
import {
|
||||||
|
PanelSection,
|
||||||
|
SegmentedControl,
|
||||||
|
SliderControl,
|
||||||
|
ToggleControl,
|
||||||
|
} from '@pascal-app/editor'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
type WindowShape = DormerNode['windowShape']
|
||||||
|
type WindowRadiusMode = 'all' | 'individual'
|
||||||
|
|
||||||
|
function maxSharedRadius(width: number, height: number): number {
|
||||||
|
return Math.max(0, Math.min(width / 2, height / 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Window tab of the dormer inspector: Hung Wall, Opening, Shape
|
||||||
|
* (with rounded/arch sub-controls), Frame, Grid, Sill. Owns local UI
|
||||||
|
* state for the "All vs Individual" corner-radius view mode — derived
|
||||||
|
* from tuple uniformity by default.
|
||||||
|
*/
|
||||||
|
export function DormerWindowSection({
|
||||||
|
node,
|
||||||
|
previewProp,
|
||||||
|
commitProp,
|
||||||
|
handleUpdate,
|
||||||
|
}: {
|
||||||
|
node: DormerNode
|
||||||
|
previewProp: (updates: Partial<DormerNode>) => void
|
||||||
|
commitProp: (updates: Partial<DormerNode>) => void
|
||||||
|
handleUpdate: (updates: Partial<DormerNode>) => void
|
||||||
|
}) {
|
||||||
|
const [radiusViewMode, setRadiusViewMode] = useState<WindowRadiusMode>('all')
|
||||||
|
|
||||||
|
const windowShape: WindowShape = node.windowShape
|
||||||
|
const windowCornerRadii: [number, number, number, number] = [...node.windowCornerRadii]
|
||||||
|
const windowArchHeight = node.windowArchHeight
|
||||||
|
const maxRadius = Math.max(0.01, maxSharedRadius(node.windowWidth, node.windowHeight))
|
||||||
|
|
||||||
|
const tupleIsUniform =
|
||||||
|
windowCornerRadii[0] === windowCornerRadii[1] &&
|
||||||
|
windowCornerRadii[1] === windowCornerRadii[2] &&
|
||||||
|
windowCornerRadii[2] === windowCornerRadii[3]
|
||||||
|
const sharedRadius = windowCornerRadii[0]
|
||||||
|
|
||||||
|
const setCornerRadius = (index: number, value: number, commit: boolean) => {
|
||||||
|
const next = [...windowCornerRadii] as [number, number, number, number]
|
||||||
|
next[index] = value
|
||||||
|
if (commit) commitProp({ windowCornerRadii: next })
|
||||||
|
else previewProp({ windowCornerRadii: next })
|
||||||
|
}
|
||||||
|
|
||||||
|
const setAllCornerRadii = (value: number, commit: boolean) => {
|
||||||
|
const next: [number, number, number, number] = [value, value, value, value]
|
||||||
|
if (commit) commitProp({ windowCornerRadii: next })
|
||||||
|
else previewProp({ windowCornerRadii: next })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PanelSection title="Hung Wall">
|
||||||
|
<SliderControl
|
||||||
|
label="Height"
|
||||||
|
max={6}
|
||||||
|
min={0.2}
|
||||||
|
onChange={(v) => previewProp({ wallSkirtHeight: v })}
|
||||||
|
onCommit={(v) => commitProp({ wallSkirtHeight: v })}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.05}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round(node.wallSkirtHeight * 100) / 100}
|
||||||
|
/>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection title="Opening">
|
||||||
|
<SliderControl
|
||||||
|
label="Width"
|
||||||
|
max={Math.max(0.5, node.width - 0.1)}
|
||||||
|
min={0.2}
|
||||||
|
onChange={(v) => previewProp({ windowWidth: v })}
|
||||||
|
onCommit={(v) => commitProp({ windowWidth: v })}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.05}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round(node.windowWidth * 100) / 100}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Height"
|
||||||
|
max={Math.max(0.2, node.wallSkirtHeight - 0.1)}
|
||||||
|
min={0.2}
|
||||||
|
onChange={(v) => previewProp({ windowHeight: v })}
|
||||||
|
onCommit={(v) => commitProp({ windowHeight: v })}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.05}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round(node.windowHeight * 100) / 100}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Offset X"
|
||||||
|
max={1}
|
||||||
|
min={-1}
|
||||||
|
onChange={(v) => previewProp({ windowOffsetX: v })}
|
||||||
|
onCommit={(v) => commitProp({ windowOffsetX: v })}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.05}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round(node.windowOffsetX * 100) / 100}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Offset Y"
|
||||||
|
max={2}
|
||||||
|
min={0}
|
||||||
|
onChange={(v) => previewProp({ windowOffsetY: v })}
|
||||||
|
onCommit={(v) => commitProp({ windowOffsetY: v })}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.05}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round(node.windowOffsetY * 100) / 100}
|
||||||
|
/>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection title="Shape">
|
||||||
|
<SegmentedControl
|
||||||
|
onChange={(v) =>
|
||||||
|
handleUpdate({
|
||||||
|
windowShape: v as WindowShape,
|
||||||
|
...(v === 'rounded'
|
||||||
|
? {
|
||||||
|
windowCornerRadii: windowCornerRadii.map((r) =>
|
||||||
|
Math.min(r, maxRadius),
|
||||||
|
) as [number, number, number, number],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
options={[
|
||||||
|
{ value: 'rectangle', label: 'Rect' },
|
||||||
|
{ value: 'rounded', label: 'Rounded' },
|
||||||
|
{ value: 'arch', label: 'Arch' },
|
||||||
|
]}
|
||||||
|
value={windowShape}
|
||||||
|
/>
|
||||||
|
{windowShape === 'rounded' && (
|
||||||
|
<div className="mt-2 flex flex-col gap-1">
|
||||||
|
<SegmentedControl
|
||||||
|
onChange={(v) => setRadiusViewMode(v as WindowRadiusMode)}
|
||||||
|
options={[
|
||||||
|
{ value: 'all', label: 'All' },
|
||||||
|
{ value: 'individual', label: 'Individual' },
|
||||||
|
]}
|
||||||
|
value={tupleIsUniform ? radiusViewMode : 'individual'}
|
||||||
|
/>
|
||||||
|
{tupleIsUniform && radiusViewMode === 'all' ? (
|
||||||
|
<SliderControl
|
||||||
|
label="Corner Radius"
|
||||||
|
max={maxRadius}
|
||||||
|
min={0}
|
||||||
|
onChange={(v) => setAllCornerRadii(v, false)}
|
||||||
|
onCommit={(v) => setAllCornerRadii(v, true)}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.01}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round(sharedRadius * 100) / 100}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
(
|
||||||
|
[
|
||||||
|
['Top Left', 0],
|
||||||
|
['Top Right', 1],
|
||||||
|
['Bottom Right', 2],
|
||||||
|
['Bottom Left', 3],
|
||||||
|
] as const
|
||||||
|
).map(([label, index]) => (
|
||||||
|
<SliderControl
|
||||||
|
key={label}
|
||||||
|
label={label}
|
||||||
|
max={maxRadius}
|
||||||
|
min={0}
|
||||||
|
onChange={(v) => setCornerRadius(index, v, false)}
|
||||||
|
onCommit={(v) => setCornerRadius(index, v, true)}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.01}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round((windowCornerRadii[index] ?? 0) * 100) / 100}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{windowShape === 'arch' && (
|
||||||
|
<SliderControl
|
||||||
|
label="Arch Height"
|
||||||
|
max={Math.max(0.1, node.windowHeight)}
|
||||||
|
min={0.1}
|
||||||
|
onChange={(v) => previewProp({ windowArchHeight: v })}
|
||||||
|
onCommit={(v) => commitProp({ windowArchHeight: v })}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.05}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round(windowArchHeight * 100) / 100}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection title="Frame">
|
||||||
|
<SliderControl
|
||||||
|
label="Thickness"
|
||||||
|
max={0.15}
|
||||||
|
min={0.01}
|
||||||
|
onChange={(v) => previewProp({ windowFrameThickness: v })}
|
||||||
|
onCommit={(v) => commitProp({ windowFrameThickness: v })}
|
||||||
|
precision={3}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.005}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round(node.windowFrameThickness * 1000) / 1000}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Depth"
|
||||||
|
max={0.15}
|
||||||
|
min={0.02}
|
||||||
|
onChange={(v) => previewProp({ windowFrameDepth: v })}
|
||||||
|
onCommit={(v) => commitProp({ windowFrameDepth: v })}
|
||||||
|
precision={3}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.005}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round(node.windowFrameDepth * 1000) / 1000}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Divider"
|
||||||
|
max={0.06}
|
||||||
|
min={0}
|
||||||
|
onChange={(v) => previewProp({ windowDividerThickness: v })}
|
||||||
|
onCommit={(v) => commitProp({ windowDividerThickness: v })}
|
||||||
|
precision={3}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.002}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round(node.windowDividerThickness * 1000) / 1000}
|
||||||
|
/>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection title="Grid">
|
||||||
|
<SliderControl
|
||||||
|
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))) })
|
||||||
|
}
|
||||||
|
precision={0}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={1}
|
||||||
|
value={node.windowColumns}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Rows"
|
||||||
|
max={8}
|
||||||
|
min={1}
|
||||||
|
onChange={(v) => previewProp({ windowRows: Math.max(1, Math.min(8, Math.round(v))) })}
|
||||||
|
onCommit={(v) => commitProp({ windowRows: Math.max(1, Math.min(8, Math.round(v))) })}
|
||||||
|
precision={0}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={1}
|
||||||
|
value={node.windowRows}
|
||||||
|
/>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection title="Sill">
|
||||||
|
<ToggleControl
|
||||||
|
checked={node.windowSill}
|
||||||
|
label="Enable Sill"
|
||||||
|
onChange={(checked) => handleUpdate({ windowSill: checked })}
|
||||||
|
/>
|
||||||
|
{node.windowSill && (
|
||||||
|
<div className="mt-1 flex flex-col gap-1">
|
||||||
|
<SliderControl
|
||||||
|
label="Depth"
|
||||||
|
max={0.3}
|
||||||
|
min={0.02}
|
||||||
|
onChange={(v) => previewProp({ windowSillDepth: v })}
|
||||||
|
onCommit={(v) => commitProp({ windowSillDepth: v })}
|
||||||
|
precision={3}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.01}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round(node.windowSillDepth * 1000) / 1000}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Thickness"
|
||||||
|
max={0.1}
|
||||||
|
min={0.01}
|
||||||
|
onChange={(v) => previewProp({ windowSillThickness: v })}
|
||||||
|
onCommit={(v) => commitProp({ windowSillThickness: v })}
|
||||||
|
precision={3}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.005}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round(node.windowSillThickness * 1000) / 1000}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PanelSection>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import {
|
||||||
|
type AnyNode,
|
||||||
|
type AnyNodeId,
|
||||||
|
type DormerNode,
|
||||||
|
type RoofNode,
|
||||||
|
type RoofSegmentNode,
|
||||||
|
useLiveNodeOverrides,
|
||||||
|
useScene,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import {
|
||||||
|
cn,
|
||||||
|
PanelSection,
|
||||||
|
PanelWrapper,
|
||||||
|
SliderControl,
|
||||||
|
triggerSFX,
|
||||||
|
useEditor,
|
||||||
|
} from '@pascal-app/editor'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
import { DormerActionsSection } from './panel-actions-section'
|
||||||
|
import { DormerPositionSection } from './panel-position-section'
|
||||||
|
import { DormerWindowSection } from './panel-window-section'
|
||||||
|
|
||||||
|
type RoofType = DormerNode['roofType']
|
||||||
|
type DormerSection = 'dormer' | 'window'
|
||||||
|
|
||||||
|
const ROOF_TYPE_OPTIONS: Array<{ label: string; value: RoofType }> = [
|
||||||
|
{ label: 'Gable', value: 'gable' },
|
||||||
|
{ label: 'Hip', value: 'hip' },
|
||||||
|
{ label: 'Shed', value: 'shed' },
|
||||||
|
{ label: 'Gambrel', value: 'gambrel' },
|
||||||
|
{ label: 'Dutch', value: 'dutch' },
|
||||||
|
{ label: 'Mansard', value: 'mansard' },
|
||||||
|
{ label: 'Flat', value: 'flat' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const SECTION_OPTIONS: Array<{ label: string; value: DormerSection }> = [
|
||||||
|
{ label: 'Dormer', value: 'dormer' },
|
||||||
|
{ label: 'Window', value: 'window' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function DormerPanel() {
|
||||||
|
const [section, setSection] = useState<DormerSection>('dormer')
|
||||||
|
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||||
|
const setSelection = useViewer((s) => s.setSelection)
|
||||||
|
const updateNode = useScene((s) => s.updateNode)
|
||||||
|
const deleteNode = useScene((s) => s.deleteNode)
|
||||||
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||||
|
|
||||||
|
const storeNode = useScene((s) =>
|
||||||
|
selectedId ? (s.nodes[selectedId as AnyNode['id']] as DormerNode | undefined) : undefined,
|
||||||
|
)
|
||||||
|
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 handleUpdate = useCallback(
|
||||||
|
(updates: Partial<DormerNode>) => {
|
||||||
|
if (!selectedId) return
|
||||||
|
updateNode(selectedId as AnyNode['id'], updates)
|
||||||
|
},
|
||||||
|
[selectedId, updateNode],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Slider drag → write live override; release → commit.
|
||||||
|
const previewProp = useCallback(
|
||||||
|
(updates: Partial<DormerNode>) => {
|
||||||
|
if (!selectedId) return
|
||||||
|
useLiveNodeOverrides.getState().set(selectedId as AnyNodeId, updates)
|
||||||
|
},
|
||||||
|
[selectedId],
|
||||||
|
)
|
||||||
|
const commitProp = useCallback(
|
||||||
|
(updates: Partial<DormerNode>) => {
|
||||||
|
if (!selectedId) return
|
||||||
|
updateNode(selectedId as AnyNode['id'], updates)
|
||||||
|
if (updates.roofSegmentId !== undefined) {
|
||||||
|
const state = useScene.getState()
|
||||||
|
const prev = node?.roofSegmentId
|
||||||
|
if (prev) state.dirtyNodes.add(prev as AnyNodeId)
|
||||||
|
state.dirtyNodes.add(updates.roofSegmentId as AnyNodeId)
|
||||||
|
state.dirtyNodes.add(selectedId as AnyNodeId)
|
||||||
|
}
|
||||||
|
useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId)
|
||||||
|
},
|
||||||
|
[node, selectedId, updateNode],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setSelection({ selectedIds: [] })
|
||||||
|
}, [setSelection])
|
||||||
|
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
if (node?.roofSegmentId) {
|
||||||
|
setSelection({ selectedIds: [node.roofSegmentId as AnyNode['id']] })
|
||||||
|
}
|
||||||
|
}, [node?.roofSegmentId, setSelection])
|
||||||
|
|
||||||
|
const handleMove = useCallback(() => {
|
||||||
|
if (!(node && selectedId)) return
|
||||||
|
triggerSFX('sfx:item-pick')
|
||||||
|
setMovingNode(node)
|
||||||
|
setSelection({ selectedIds: [] })
|
||||||
|
}, [node, selectedId, setMovingNode, setSelection])
|
||||||
|
|
||||||
|
const handleDuplicate = useCallback(() => {
|
||||||
|
if (!(node && node.roofSegmentId)) return
|
||||||
|
triggerSFX('sfx:item-pick')
|
||||||
|
// Deep clone and strip the id so the move tool's onClick branch
|
||||||
|
// (`isNew || !node.id`) takes the "create fresh" path. Setting
|
||||||
|
// `metadata.isNew = true` is what gates the move tool from
|
||||||
|
// updating any existing node — the dormer is only added to the
|
||||||
|
// scene on click, not when the Duplicate button is pressed.
|
||||||
|
const cloned = structuredClone(node) as DormerNode & { id?: AnyNodeId }
|
||||||
|
delete (cloned as { id?: AnyNodeId }).id
|
||||||
|
const prevMeta =
|
||||||
|
cloned.metadata && typeof cloned.metadata === 'object' && !Array.isArray(cloned.metadata)
|
||||||
|
? (cloned.metadata as Record<string, unknown>)
|
||||||
|
: {}
|
||||||
|
cloned.metadata = { ...prevMeta, isNew: true }
|
||||||
|
setMovingNode(cloned as DormerNode)
|
||||||
|
setSelection({ selectedIds: [] })
|
||||||
|
}, [node, setMovingNode, setSelection])
|
||||||
|
|
||||||
|
const handleDelete = useCallback(() => {
|
||||||
|
if (!(selectedId && node)) return
|
||||||
|
triggerSFX('sfx:item-delete')
|
||||||
|
const segmentId = node.roofSegmentId
|
||||||
|
if (segmentId) {
|
||||||
|
const state = useScene.getState()
|
||||||
|
const segment = state.nodes[segmentId as AnyNodeId] as RoofSegmentNode | undefined
|
||||||
|
if (segment) {
|
||||||
|
state.updateNode(segmentId as AnyNode['id'], {
|
||||||
|
children: (segment.children ?? []).filter((id) => id !== selectedId),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deleteNode(selectedId as AnyNodeId)
|
||||||
|
if (segmentId) {
|
||||||
|
useScene.getState().dirtyNodes.add(segmentId as AnyNodeId)
|
||||||
|
setSelection({ selectedIds: [segmentId as AnyNode['id']] })
|
||||||
|
} else {
|
||||||
|
setSelection({ selectedIds: [] })
|
||||||
|
}
|
||||||
|
}, [selectedId, node, deleteNode, setSelection])
|
||||||
|
|
||||||
|
if (!(node && node.type === 'dormer' && selectedId)) return null
|
||||||
|
|
||||||
|
const scenestate = useScene.getState()
|
||||||
|
const segment = node.roofSegmentId
|
||||||
|
? (scenestate.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined)
|
||||||
|
: undefined
|
||||||
|
const roof = segment?.parentId
|
||||||
|
? (scenestate.nodes[segment.parentId as AnyNodeId] as RoofNode | undefined)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PanelWrapper
|
||||||
|
icon="/icons/roof.png"
|
||||||
|
onBack={node.roofSegmentId ? handleBack : undefined}
|
||||||
|
onClose={handleClose}
|
||||||
|
title={node.name || 'Dormer'}
|
||||||
|
width={300}
|
||||||
|
>
|
||||||
|
<DormerPositionSection
|
||||||
|
commitProp={commitProp}
|
||||||
|
node={node}
|
||||||
|
previewProp={previewProp}
|
||||||
|
roof={roof}
|
||||||
|
segment={segment}
|
||||||
|
selectedId={selectedId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PanelSection title="Section">
|
||||||
|
<div className="grid grid-cols-3 gap-1.5 px-1 pt-1">
|
||||||
|
{SECTION_OPTIONS.map((option) => {
|
||||||
|
const isSelected = section === option.value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'flex min-h-10 items-center justify-center rounded-lg border px-2 py-2 text-center text-xs transition-colors',
|
||||||
|
isSelected
|
||||||
|
? 'border-orange-400/60 bg-orange-400/10 text-foreground'
|
||||||
|
: 'border-border/50 bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground',
|
||||||
|
)}
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => setSection(option.value)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="truncate font-medium">{option.label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
{section === 'dormer' && (
|
||||||
|
<>
|
||||||
|
<PanelSection title="Dimensions">
|
||||||
|
<SliderControl
|
||||||
|
label="Width"
|
||||||
|
max={4}
|
||||||
|
min={0.5}
|
||||||
|
onChange={(v) => previewProp({ width: v })}
|
||||||
|
onCommit={(v) => commitProp({ width: v })}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.05}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round(node.width * 100) / 100}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Depth"
|
||||||
|
max={5}
|
||||||
|
min={0.5}
|
||||||
|
onChange={(v) => previewProp({ depth: v })}
|
||||||
|
onCommit={(v) => commitProp({ depth: v })}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.05}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round(node.depth * 100) / 100}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Wall Height"
|
||||||
|
max={5}
|
||||||
|
min={0}
|
||||||
|
onChange={(v) => previewProp({ height: v })}
|
||||||
|
onCommit={(v) => commitProp({ height: v })}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.05}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round(node.height * 100) / 100}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Roof Height"
|
||||||
|
max={3}
|
||||||
|
min={0}
|
||||||
|
onChange={(v) => previewProp({ roofHeight: v })}
|
||||||
|
onCommit={(v) => commitProp({ roofHeight: v })}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.05}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round(node.roofHeight * 100) / 100}
|
||||||
|
/>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection title="Roof Type">
|
||||||
|
<div className="grid grid-cols-3 gap-1.5 px-1 pt-1">
|
||||||
|
{ROOF_TYPE_OPTIONS.map((option) => {
|
||||||
|
const isSelected = node.roofType === option.value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'flex min-h-10 items-center justify-center rounded-lg border px-2 py-2 text-xs transition-colors',
|
||||||
|
isSelected
|
||||||
|
? 'border-orange-400/60 bg-orange-400/10 text-foreground'
|
||||||
|
: 'border-border/50 bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground',
|
||||||
|
)}
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => handleUpdate({ roofType: option.value })}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="truncate font-medium">{option.label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</PanelSection>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{section === 'window' && (
|
||||||
|
<DormerWindowSection
|
||||||
|
commitProp={commitProp}
|
||||||
|
handleUpdate={handleUpdate}
|
||||||
|
node={node}
|
||||||
|
previewProp={previewProp}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DormerActionsSection
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onDuplicate={handleDuplicate}
|
||||||
|
onMove={handleMove}
|
||||||
|
/>
|
||||||
|
</PanelWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import type { ParametricDescriptor } from '@pascal-app/core'
|
||||||
|
import { dormerSupportsArch } from './geometry'
|
||||||
|
import type { DormerNode } from './schema'
|
||||||
|
|
||||||
|
export const dormerParametrics: ParametricDescriptor<DormerNode> = {
|
||||||
|
// Bespoke tabbed UI (Dormer / Window / Frame / Grid / Sill) — same
|
||||||
|
// pattern as chimney. `groups` stays for the MCP path / fallback
|
||||||
|
// consumer, but the inspector mounts the custom panel.
|
||||||
|
customPanel: () => import('./panel'),
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
label: 'Dormer',
|
||||||
|
fields: [
|
||||||
|
{ key: 'width', kind: 'number', unit: 'm', min: 0.5, max: 4, step: 0.05 },
|
||||||
|
{ key: 'depth', kind: 'number', unit: 'm', min: 0.5, max: 5, step: 0.05 },
|
||||||
|
{ key: 'height', kind: 'number', unit: 'm', min: 0, max: 5, step: 0.05 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Dormer roof',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'roofType',
|
||||||
|
kind: 'enum',
|
||||||
|
options: ['hip', 'gable', 'shed', 'gambrel', 'dutch', 'mansard', 'flat'],
|
||||||
|
display: 'select',
|
||||||
|
},
|
||||||
|
{ key: 'roofHeight', kind: 'number', unit: 'm', min: 0, max: 2, step: 0.05 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Hung wall',
|
||||||
|
fields: [
|
||||||
|
{ key: 'wallSkirtHeight', kind: 'number', unit: 'm', min: 0.2, max: 6, step: 0.05 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Window opening',
|
||||||
|
fields: [
|
||||||
|
{ key: 'windowWidth', kind: 'number', unit: 'm', min: 0.2, max: 3, step: 0.05 },
|
||||||
|
{ key: 'windowHeight', kind: 'number', unit: 'm', min: 0.2, max: 6, step: 0.05 },
|
||||||
|
{ key: 'windowOffsetX', kind: 'number', unit: 'm', min: -1, max: 1, step: 0.05 },
|
||||||
|
{ key: 'windowOffsetY', kind: 'number', unit: 'm', min: 0, max: 2, step: 0.05 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Window grid',
|
||||||
|
fields: [
|
||||||
|
{ key: 'windowColumns', kind: 'number', min: 1, max: 8, step: 1 },
|
||||||
|
{ key: 'windowRows', kind: 'number', min: 1, max: 8, step: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Window frame',
|
||||||
|
fields: [
|
||||||
|
{ 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: 'windowShape',
|
||||||
|
kind: 'enum',
|
||||||
|
options: ['rectangle', 'rounded', 'arch'],
|
||||||
|
display: 'segmented',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'windowArchHeight',
|
||||||
|
kind: 'number',
|
||||||
|
unit: 'm',
|
||||||
|
min: 0.1,
|
||||||
|
max: 1,
|
||||||
|
step: 0.05,
|
||||||
|
visibleIf: dormerSupportsArch,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Sill',
|
||||||
|
fields: [
|
||||||
|
{ key: 'windowSill', kind: 'boolean' },
|
||||||
|
{
|
||||||
|
key: 'windowSillDepth',
|
||||||
|
kind: 'number',
|
||||||
|
unit: 'm',
|
||||||
|
min: 0.02,
|
||||||
|
max: 0.3,
|
||||||
|
step: 0.01,
|
||||||
|
visibleIf: (n) => n.windowSill === true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'windowSillThickness',
|
||||||
|
kind: 'number',
|
||||||
|
unit: 'm',
|
||||||
|
min: 0.01,
|
||||||
|
max: 0.1,
|
||||||
|
step: 0.005,
|
||||||
|
visibleIf: (n) => n.windowSill === true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useMemo } from 'react'
|
||||||
|
import * as THREE from 'three'
|
||||||
|
import { buildDormerGhostGeometry } from './geometry'
|
||||||
|
import type { DormerNode } from './schema'
|
||||||
|
|
||||||
|
const ghostMaterial = new THREE.MeshStandardMaterial({
|
||||||
|
color: 0x88_88_88,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.45,
|
||||||
|
depthWrite: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const DormerPreview = ({ node }: { node: DormerNode }) => {
|
||||||
|
const geo = useMemo(
|
||||||
|
() => buildDormerGhostGeometry(node),
|
||||||
|
[node.width, node.depth, node.height, node.roofHeight, node.roofType, node.wallSkirtHeight],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => () => geo.dispose(), [geo])
|
||||||
|
|
||||||
|
return <mesh geometry={geo} material={ghostMaterial} raycast={() => {}} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DormerPreview
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import {
|
||||||
|
type AnyNodeId,
|
||||||
|
type DormerNode,
|
||||||
|
getEffectiveDormerSurfaceMaterial,
|
||||||
|
type RoofSegmentNode,
|
||||||
|
useLiveNodeOverrides,
|
||||||
|
useRegistry,
|
||||||
|
useScene,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import {
|
||||||
|
createMaterial,
|
||||||
|
createMaterialFromPresetRef,
|
||||||
|
useNodeEvents,
|
||||||
|
} from '@pascal-app/viewer'
|
||||||
|
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)
|
||||||
|
const handlers = useNodeEvents(storeNode, 'dormer')
|
||||||
|
|
||||||
|
// Live overrides so slider drag updates the dormer without committing
|
||||||
|
// to the store. While any override is live we render the cheap
|
||||||
|
// fallback silhouette — running the full CSG on every pointer move is
|
||||||
|
// far too expensive (multiple boolean ops + ground subtract +
|
||||||
|
// 32-segment arch curves). Commit clears the override and the real
|
||||||
|
// CSG mesh kicks back in.
|
||||||
|
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,
|
||||||
|
[storeNode, liveOverrides],
|
||||||
|
)
|
||||||
|
|
||||||
|
const segment = useScene((state) =>
|
||||||
|
node.roofSegmentId
|
||||||
|
? (state.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined)
|
||||||
|
: undefined,
|
||||||
|
)
|
||||||
|
|
||||||
|
const resolvedMaterials = useMemo(() => {
|
||||||
|
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 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
|
||||||
|
return [w, s, w, t, w] as THREE.Material[]
|
||||||
|
}, [
|
||||||
|
node.material,
|
||||||
|
node.materialPreset,
|
||||||
|
node.topMaterial,
|
||||||
|
node.topMaterialPreset,
|
||||||
|
node.sideMaterial,
|
||||||
|
node.sideMaterialPreset,
|
||||||
|
node.wallMaterial,
|
||||||
|
node.wallMaterialPreset,
|
||||||
|
])
|
||||||
|
|
||||||
|
const material = resolvedMaterials ?? defaultDormerMaterials
|
||||||
|
const frameSideMat = resolvedMaterials ? resolvedMaterials[1]! : defaultSideMat
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
// Dormers are mounted inside `RoofRenderer`'s `roof-elements` group
|
||||||
|
// (at the roof origin — NOT inside the host segment's transform), so
|
||||||
|
// we apply the segment's own position + rotation here. Mirrors how
|
||||||
|
// chimney / skylight render. The CSG geometry is built in
|
||||||
|
// dormer-mesh-local with `dormer.position` + `dormer.rotation`
|
||||||
|
// already accounted for by `segToMesh`, so we layer them as group
|
||||||
|
// transforms here too.
|
||||||
|
return (
|
||||||
|
<group
|
||||||
|
position={segment.position}
|
||||||
|
ref={ref}
|
||||||
|
rotation-y={segment.rotation ?? 0}
|
||||||
|
visible={node.visible}
|
||||||
|
>
|
||||||
|
<group
|
||||||
|
position={[node.position[0] ?? 0, node.position[1] ?? 0, node.position[2] ?? 0]}
|
||||||
|
>
|
||||||
|
<group rotation-y={node.rotation ?? 0} {...handlers}>
|
||||||
|
<mesh
|
||||||
|
castShadow
|
||||||
|
geometry={geometry}
|
||||||
|
material={material}
|
||||||
|
name="dormer-body"
|
||||||
|
receiveShadow
|
||||||
|
/>
|
||||||
|
<DormerWindowAssembly
|
||||||
|
frameMaterial={frameSideMat}
|
||||||
|
node={node}
|
||||||
|
segment={segment}
|
||||||
|
/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export so consumers (e.g. tests) can reach the gable slot index
|
||||||
|
// without importing from `@pascal-app/viewer` directly.
|
||||||
|
export { DORMER_GABLE_MATERIAL_INDEX }
|
||||||
|
|
||||||
|
export default DormerRenderer
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export { DormerNode, getEffectiveDormerSurfaceMaterial } from '@pascal-app/core'
|
||||||
|
export type {
|
||||||
|
DormerSurfaceMaterialRole,
|
||||||
|
DormerSurfaceMaterialSpec,
|
||||||
|
} from '@pascal-app/core'
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { type AnyNodeId, DormerNode, useScene } from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { dormerDefinition } from './definition'
|
||||||
|
import DormerPreview from './preview'
|
||||||
|
import { useDormerPlacement } from './use-dormer-placement'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the smallest free integer suffix for a new dormer name so the
|
||||||
|
* scene tree doesn't end up with multiple `Dormer 3`s after deletes.
|
||||||
|
*/
|
||||||
|
function nextDormerNumber(nodes: Record<string, unknown>): number {
|
||||||
|
const used = new Set<number>()
|
||||||
|
for (const node of Object.values(nodes)) {
|
||||||
|
if (!node || typeof node !== 'object') continue
|
||||||
|
const n = node as { type?: string; name?: string }
|
||||||
|
if (n.type !== 'dormer') continue
|
||||||
|
const m = n.name?.match(/^Dormer (\d+)$/)
|
||||||
|
if (m?.[1]) used.add(Number.parseInt(m[1], 10))
|
||||||
|
}
|
||||||
|
let n = 1
|
||||||
|
while (used.has(n)) n++
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placement tool for a fresh dormer. The dormer sits UPRIGHT on the
|
||||||
|
* host segment at segment-local `y = 0` (the host wall foot) — the
|
||||||
|
* CSG inside `generateDormerGeometry` carves the dormer against the
|
||||||
|
* host roof's slope, so we don't tilt or lift it here. The ghost is
|
||||||
|
* mounted on the hit segment's world transform (extracted via the
|
||||||
|
* registry) so the user sees exactly where the dormer will land.
|
||||||
|
*/
|
||||||
|
const DormerTool = () => {
|
||||||
|
const setSelection = useViewer((s) => s.setSelection)
|
||||||
|
|
||||||
|
const previewNode = useMemo(
|
||||||
|
() =>
|
||||||
|
DormerNode.parse({
|
||||||
|
...dormerDefinition.defaults(),
|
||||||
|
name: 'Dormer',
|
||||||
|
position: [0, 0, 0],
|
||||||
|
rotation: 0,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const { activeBuildingId, segmentXform, hitLocal, ghostRotation } = useDormerPlacement({
|
||||||
|
onCommit: (hit, rotation) => {
|
||||||
|
const state = useScene.getState()
|
||||||
|
const dormer = DormerNode.parse({
|
||||||
|
...dormerDefinition.defaults(),
|
||||||
|
name: `Dormer ${nextDormerNumber(state.nodes)}`,
|
||||||
|
roofSegmentId: hit.segment.id,
|
||||||
|
parentId: hit.segment.id,
|
||||||
|
// Anchor at the slope height so the renderer matches the ghost.
|
||||||
|
// The CSG still carves cleanly because it inverts T(position)
|
||||||
|
// when bringing the host into dormer-local.
|
||||||
|
position: [hit.localX, hit.localY, hit.localZ],
|
||||||
|
rotation,
|
||||||
|
})
|
||||||
|
state.createNode(dormer, hit.segment.id as AnyNodeId)
|
||||||
|
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
||||||
|
setSelection({ selectedIds: [dormer.id] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!activeBuildingId || !segmentXform || !hitLocal) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group position={segmentXform.position} quaternion={segmentXform.quaternion}>
|
||||||
|
<group position={hitLocal}>
|
||||||
|
<group rotation-y={ghostRotation}>
|
||||||
|
<DormerPreview node={previewNode} />
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DormerTool
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import {
|
||||||
|
type AnyNodeId,
|
||||||
|
emitter,
|
||||||
|
type RoofEvent,
|
||||||
|
type RoofNode,
|
||||||
|
type RoofSegmentNode,
|
||||||
|
sceneRegistry,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import { triggerSFX } from '@pascal-app/editor'
|
||||||
|
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'
|
||||||
|
|
||||||
|
const tmpMatrix = new THREE.Matrix4()
|
||||||
|
const tmpInv = new THREE.Matrix4()
|
||||||
|
const tmpPos = new THREE.Vector3()
|
||||||
|
const tmpQuat = new THREE.Quaternion()
|
||||||
|
const tmpScale = new THREE.Vector3()
|
||||||
|
|
||||||
|
export type DormerSegmentTransform = {
|
||||||
|
position: [number, number, number]
|
||||||
|
quaternion: [number, number, number, number]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DormerPlacementHit = {
|
||||||
|
segment: RoofSegmentNode
|
||||||
|
localX: number
|
||||||
|
localY: number
|
||||||
|
localZ: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared placement-tool plumbing for fresh-place and duplicate/move
|
||||||
|
* tools. Owns:
|
||||||
|
* - cursor → roof-segment hit resolution (delegated to the host
|
||||||
|
* RoofNode pointer events)
|
||||||
|
* - building-local segment transform extraction (for ghost mounting)
|
||||||
|
* - 5cm grid snap + SFX cue
|
||||||
|
* - keyboard rotate (R / Shift+R, ±15°)
|
||||||
|
*
|
||||||
|
* Does NOT own:
|
||||||
|
* - the ghost mesh (caller renders `<DormerPreview>`)
|
||||||
|
* - any node-lifecycle state (caller passes an `onCommit` that
|
||||||
|
* decides between createNode / updateNode / etc.)
|
||||||
|
*
|
||||||
|
* Returns the segment transform + cursor hit so the caller can mount
|
||||||
|
* the ghost, plus the live ghost rotation (driven by R / Shift+R).
|
||||||
|
*/
|
||||||
|
export function useDormerPlacement(opts: {
|
||||||
|
initialRotation?: number
|
||||||
|
onCommit: (hit: DormerPlacementHit, rotation: number) => void
|
||||||
|
}): {
|
||||||
|
activeBuildingId: string | undefined
|
||||||
|
segmentXform: DormerSegmentTransform | null
|
||||||
|
hitLocal: [number, number, number] | null
|
||||||
|
ghostRotation: number
|
||||||
|
} {
|
||||||
|
const activeBuildingId = useViewer((s) => s.selection.buildingId)
|
||||||
|
|
||||||
|
const [segmentXform, setSegmentXform] = useState<DormerSegmentTransform | null>(null)
|
||||||
|
const [hitLocal, setHitLocal] = useState<[number, number, number] | null>(null)
|
||||||
|
const [ghostRotation, setGhostRotation] = useState(opts.initialRotation ?? 0)
|
||||||
|
const lastSnapRef = useRef<[number, number] | null>(null)
|
||||||
|
// Mirror of `ghostRotation` so the click handler (registered once
|
||||||
|
// inside useEffect) can read the latest value at commit time.
|
||||||
|
const ghostRotationRef = useRef(opts.initialRotation ?? 0)
|
||||||
|
// Latest commit callback, captured via ref so the useEffect doesn't
|
||||||
|
// need it in its dep list (we don't want to re-register listeners
|
||||||
|
// every time the parent rerenders).
|
||||||
|
const onCommitRef = useRef(opts.onCommit)
|
||||||
|
onCommitRef.current = opts.onCommit
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeBuildingId) return
|
||||||
|
|
||||||
|
const computeSegmentXform = (segmentId: string): DormerSegmentTransform | null => {
|
||||||
|
const buildingObj = sceneRegistry.nodes.get(activeBuildingId as AnyNodeId)
|
||||||
|
const segObj = sceneRegistry.nodes.get(segmentId as AnyNodeId)
|
||||||
|
if (!(buildingObj && segObj)) return null
|
||||||
|
buildingObj.updateWorldMatrix(true, false)
|
||||||
|
segObj.updateWorldMatrix(true, false)
|
||||||
|
tmpInv.copy(buildingObj.matrixWorld).invert()
|
||||||
|
tmpMatrix.multiplyMatrices(tmpInv, segObj.matrixWorld)
|
||||||
|
tmpMatrix.decompose(tmpPos, tmpQuat, tmpScale)
|
||||||
|
return {
|
||||||
|
position: [tmpPos.x, tmpPos.y, tmpPos.z],
|
||||||
|
quaternion: [tmpQuat.x, tmpQuat.y, tmpQuat.z, tmpQuat.w],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePreview = (event: RoofEvent) => {
|
||||||
|
const wx = event.position[0]
|
||||||
|
const wy = event.position[1]
|
||||||
|
const wz = event.position[2]
|
||||||
|
|
||||||
|
const sx = Math.round(wx / DORMER_PLACEMENT_SNAP_M) * DORMER_PLACEMENT_SNAP_M
|
||||||
|
const sz = Math.round(wz / DORMER_PLACEMENT_SNAP_M) * DORMER_PLACEMENT_SNAP_M
|
||||||
|
const prev = lastSnapRef.current
|
||||||
|
if (!prev || prev[0] !== sx || prev[1] !== sz) {
|
||||||
|
triggerSFX('sfx:grid-snap')
|
||||||
|
lastSnapRef.current = [sx, sz]
|
||||||
|
}
|
||||||
|
|
||||||
|
const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz)
|
||||||
|
if (!hit) return
|
||||||
|
const xform = computeSegmentXform(hit.segment.id)
|
||||||
|
if (!xform) return
|
||||||
|
setSegmentXform(xform)
|
||||||
|
// Lift the ghost to the actual roof-surface Y at the cursor so
|
||||||
|
// it tracks the mouse along the slope. The CSG inside
|
||||||
|
// `generateDormerGeometry` carves the dormer against the host
|
||||||
|
// roof regardless of `position[1]` — anchoring at the cursor
|
||||||
|
// height is purely a visual alignment.
|
||||||
|
setHitLocal([hit.localX, hit.localY, hit.localZ])
|
||||||
|
event.stopPropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onClick = (event: RoofEvent) => {
|
||||||
|
const hit = resolveRoofSegmentHit(
|
||||||
|
event.node as RoofNode,
|
||||||
|
event.position[0],
|
||||||
|
event.position[1],
|
||||||
|
event.position[2],
|
||||||
|
)
|
||||||
|
if (!hit) return
|
||||||
|
onCommitRef.current(hit, ghostRotationRef.current)
|
||||||
|
triggerSFX('sfx:item-place')
|
||||||
|
event.stopPropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== 'r' && e.key !== 'R') return
|
||||||
|
const target = e.target as HTMLElement | null
|
||||||
|
if (
|
||||||
|
target &&
|
||||||
|
(target.tagName === 'INPUT' ||
|
||||||
|
target.tagName === 'TEXTAREA' ||
|
||||||
|
target.isContentEditable)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
const dir = e.shiftKey ? -1 : 1
|
||||||
|
ghostRotationRef.current += dir * DORMER_PLACEMENT_ROTATION_STEP
|
||||||
|
setGhostRotation(ghostRotationRef.current)
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
emitter.on('roof:move', updatePreview)
|
||||||
|
emitter.on('roof:enter', updatePreview)
|
||||||
|
emitter.on('roof:click', onClick)
|
||||||
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
emitter.off('roof:move', updatePreview)
|
||||||
|
emitter.off('roof:enter', updatePreview)
|
||||||
|
emitter.off('roof:click', onClick)
|
||||||
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
|
}
|
||||||
|
}, [activeBuildingId])
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeBuildingId: activeBuildingId ?? undefined,
|
||||||
|
segmentXform,
|
||||||
|
hitLocal,
|
||||||
|
ghostRotation,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
'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 { buildDormerWindowGeometries, type DormerWindowShape } from './window-frame'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the window opening assembly (frame bars, glass panes, sill)
|
||||||
|
* on each exposed gable face of a dormer. Owns its geometry lifecycle
|
||||||
|
* (build via `buildDormerWindowGeometries`, dispose on unmount) so the
|
||||||
|
* renderer doesn't have to.
|
||||||
|
*
|
||||||
|
* Mounted inside the dormer's rotation group, in dormer-mesh-local
|
||||||
|
* coordinates. The CSG cut on the wall is performed separately inside
|
||||||
|
* the viewer's `generateDormerGeometry`; the geometry built here is
|
||||||
|
* sized to match that cut.
|
||||||
|
*/
|
||||||
|
const DormerWindowAssembly = ({
|
||||||
|
node,
|
||||||
|
segment,
|
||||||
|
frameMaterial,
|
||||||
|
}: {
|
||||||
|
node: DormerNode
|
||||||
|
segment: RoofSegmentNode
|
||||||
|
frameMaterial: THREE.Material
|
||||||
|
}) => {
|
||||||
|
const skirtWin = useMemo(
|
||||||
|
() => getDormerSkirtWindowDims(node),
|
||||||
|
[
|
||||||
|
node.width,
|
||||||
|
node.windowWidth,
|
||||||
|
node.windowHeight,
|
||||||
|
node.windowOffsetX,
|
||||||
|
node.windowOffsetY,
|
||||||
|
node.wallSkirtHeight,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
const winW = skirtWin.width
|
||||||
|
const winH = skirtWin.height
|
||||||
|
const winShape: DormerWindowShape = node.windowShape
|
||||||
|
const resolvedRadii: [number, number, number, number] = [...node.windowCornerRadii]
|
||||||
|
|
||||||
|
const winGeo = useMemo(
|
||||||
|
() =>
|
||||||
|
buildDormerWindowGeometries(
|
||||||
|
winW,
|
||||||
|
winH,
|
||||||
|
node.windowFrameThickness,
|
||||||
|
node.windowFrameDepth,
|
||||||
|
node.windowColumns,
|
||||||
|
node.windowRows,
|
||||||
|
node.windowDividerThickness,
|
||||||
|
winShape,
|
||||||
|
node.windowArchHeight,
|
||||||
|
resolvedRadii,
|
||||||
|
),
|
||||||
|
[
|
||||||
|
winW,
|
||||||
|
winH,
|
||||||
|
node.windowFrameThickness,
|
||||||
|
node.windowFrameDepth,
|
||||||
|
node.windowColumns,
|
||||||
|
node.windowRows,
|
||||||
|
node.windowDividerThickness,
|
||||||
|
winShape,
|
||||||
|
node.windowArchHeight,
|
||||||
|
...resolvedRadii,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
const disposed = new Set<THREE.BufferGeometry>()
|
||||||
|
for (const bar of winGeo.frameBars) {
|
||||||
|
if (!disposed.has(bar.geo)) {
|
||||||
|
bar.geo.dispose()
|
||||||
|
disposed.add(bar.geo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const pane of winGeo.glassPanes) {
|
||||||
|
if (!disposed.has(pane.geo)) {
|
||||||
|
pane.geo.dispose()
|
||||||
|
disposed.add(pane.geo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [winGeo])
|
||||||
|
|
||||||
|
const sillEnabled = node.windowSill !== false
|
||||||
|
const sillT = Math.max(0.001, node.windowSillThickness)
|
||||||
|
const sillD = Math.max(0.001, node.windowSillDepth)
|
||||||
|
const sillW = winW + 0.06 // 3 cm overhang each side
|
||||||
|
const sillGeo = useMemo(
|
||||||
|
() => (sillEnabled ? new THREE.BoxGeometry(sillW, sillT, sillD) : null),
|
||||||
|
[sillEnabled, sillW, sillT, sillD],
|
||||||
|
)
|
||||||
|
useEffect(() => () => sillGeo?.dispose(), [sillGeo])
|
||||||
|
|
||||||
|
const exposed = useMemo(
|
||||||
|
() => getDormerExposedFaces(node, segment),
|
||||||
|
[
|
||||||
|
segment,
|
||||||
|
node.roofType,
|
||||||
|
node.width,
|
||||||
|
node.depth,
|
||||||
|
node.height,
|
||||||
|
node.roofHeight,
|
||||||
|
node.position[0],
|
||||||
|
node.position[1],
|
||||||
|
node.position[2],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
const gableHalfZ = node.depth / 2
|
||||||
|
const winX = skirtWin.offsetX
|
||||||
|
const winY = skirtWin.centerY
|
||||||
|
|
||||||
|
const renderFace = (zPos: number, outDir: number, keyPrefix: string) => (
|
||||||
|
<group name={`dormer-window-${keyPrefix}`} position={[winX, winY, zPos]}>
|
||||||
|
{winGeo.glassPanes.map((pane, i) => (
|
||||||
|
<mesh
|
||||||
|
geometry={pane.geo}
|
||||||
|
// biome-ignore lint/suspicious/noArrayIndexKey: glass panes are derived from grid indices, no stable id.
|
||||||
|
key={`${keyPrefix}-glass-${i}`}
|
||||||
|
material={glassMaterial}
|
||||||
|
name={`dormer-glass-${keyPrefix}-${i}`}
|
||||||
|
position={pane.pos}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{winGeo.frameBars.map((bar, i) => (
|
||||||
|
<mesh
|
||||||
|
castShadow
|
||||||
|
geometry={bar.geo}
|
||||||
|
// biome-ignore lint/suspicious/noArrayIndexKey: frame bars are derived from grid indices, no stable id.
|
||||||
|
key={`${keyPrefix}-bar-${i}`}
|
||||||
|
material={frameMaterial}
|
||||||
|
name={`dormer-frame-${keyPrefix}-${i}`}
|
||||||
|
position={bar.pos}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{sillGeo && (
|
||||||
|
<mesh
|
||||||
|
castShadow
|
||||||
|
geometry={sillGeo}
|
||||||
|
material={frameMaterial}
|
||||||
|
name={`dormer-sill-${keyPrefix}`}
|
||||||
|
position={[0, -winH / 2 - sillT / 2, (outDir * sillD) / 2]}
|
||||||
|
receiveShadow
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{exposed.front && renderFace(gableHalfZ, +1, 'front')}
|
||||||
|
{exposed.back && renderFace(-gableHalfZ, -1, 'back')}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DormerWindowAssembly
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import * as THREE from 'three'
|
||||||
|
import { createDormerArchShape, createDormerRoundedShape } from './csg-geometry'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frame + glass geometry for the window opening on a dormer's gable
|
||||||
|
* face. The extruded frame profile uses the same shape builders as the
|
||||||
|
* CSG cut in the viewer (`generateDormerGeometry`), so the frame sits
|
||||||
|
* flush in the wall — keeping the cut and the frame visually in sync.
|
||||||
|
*
|
||||||
|
* Only the frame bars and glass panes are produced here; the wall
|
||||||
|
* opening itself is CSG-subtracted from the dormer body inside the
|
||||||
|
* viewer's `generateDormerGeometry`.
|
||||||
|
*/
|
||||||
|
export type DormerWindowShape = 'rectangle' | 'rounded' | 'arch'
|
||||||
|
|
||||||
|
export type WindowGeometries = {
|
||||||
|
frameBars: { geo: THREE.BufferGeometry; pos: [number, number, number] }[]
|
||||||
|
glassPanes: { geo: THREE.BufferGeometry; pos: [number, number, number] }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDormerWindowGeometries(
|
||||||
|
winW: number,
|
||||||
|
winH: number,
|
||||||
|
ft: number,
|
||||||
|
fd: number,
|
||||||
|
cols: number,
|
||||||
|
rows: number,
|
||||||
|
dt: number,
|
||||||
|
shape: DormerWindowShape = 'rectangle',
|
||||||
|
archHeight = 0.35,
|
||||||
|
cornerRadii: [number, number, number, number] = [0.15, 0.15, 0.15, 0.15],
|
||||||
|
): WindowGeometries {
|
||||||
|
const safeFt = Math.max(0.001, ft)
|
||||||
|
const safeDt = Math.max(0.001, dt)
|
||||||
|
const innerW = Math.max(0.01, winW - 2 * safeFt)
|
||||||
|
const innerH = Math.max(0.01, winH - 2 * safeFt)
|
||||||
|
const hw = winW / 2
|
||||||
|
const hh = winH / 2
|
||||||
|
|
||||||
|
const frameBars: WindowGeometries['frameBars'] = []
|
||||||
|
const glassPanes: WindowGeometries['glassPanes'] = []
|
||||||
|
|
||||||
|
if (shape === 'arch' || shape === 'rounded') {
|
||||||
|
const insetRadii = cornerRadii.map((r) => Math.max(r - safeFt, 0)) as [
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
]
|
||||||
|
const outerShape =
|
||||||
|
shape === 'arch'
|
||||||
|
? createDormerArchShape(winW, winH, archHeight)
|
||||||
|
: createDormerRoundedShape(winW, winH, cornerRadii)
|
||||||
|
|
||||||
|
const innerHole =
|
||||||
|
shape === 'arch'
|
||||||
|
? 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)
|
||||||
|
const frameGeo = new THREE.ExtrudeGeometry(outerShape, {
|
||||||
|
depth: fd,
|
||||||
|
bevelEnabled: false,
|
||||||
|
curveSegments: 24,
|
||||||
|
})
|
||||||
|
frameGeo.translate(0, 0, -fd / 2)
|
||||||
|
frameBars.push({ geo: frameGeo, pos: [0, 0, 0] })
|
||||||
|
|
||||||
|
const colDividerCount = cols - 1
|
||||||
|
const totalColDividerW = colDividerCount * safeDt
|
||||||
|
const paneAreaW = Math.max(0.01, innerW - totalColDividerW)
|
||||||
|
const paneW = paneAreaW / cols
|
||||||
|
|
||||||
|
for (let c = 1; c < cols; c++) {
|
||||||
|
const x = -innerW / 2 + c * paneW + (c - 0.5) * safeDt
|
||||||
|
frameBars.push({ geo: new THREE.BoxGeometry(safeDt, innerH, fd), pos: [x, 0, 0] })
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowDividerCount = rows - 1
|
||||||
|
const totalRowDividerH = rowDividerCount * safeDt
|
||||||
|
const paneAreaH = Math.max(0.01, innerH - totalRowDividerH)
|
||||||
|
const paneH = paneAreaH / rows
|
||||||
|
|
||||||
|
for (let r = 1; r < rows; r++) {
|
||||||
|
const y = -innerH / 2 + r * paneH + (r - 0.5) * safeDt
|
||||||
|
frameBars.push({ geo: new THREE.BoxGeometry(innerW, safeDt, fd), pos: [0, y, 0] })
|
||||||
|
}
|
||||||
|
|
||||||
|
const glassShape =
|
||||||
|
shape === 'arch'
|
||||||
|
? 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,
|
||||||
|
bevelEnabled: false,
|
||||||
|
curveSegments: 24,
|
||||||
|
})
|
||||||
|
glassGeo.translate(0, 0, -0.004)
|
||||||
|
glassPanes.push({ geo: glassGeo, pos: [0, 0, 0] })
|
||||||
|
} else {
|
||||||
|
frameBars.push({
|
||||||
|
geo: new THREE.BoxGeometry(winW, safeFt, fd),
|
||||||
|
pos: [0, hh - safeFt / 2, 0],
|
||||||
|
})
|
||||||
|
frameBars.push({
|
||||||
|
geo: new THREE.BoxGeometry(winW, safeFt, fd),
|
||||||
|
pos: [0, -hh + safeFt / 2, 0],
|
||||||
|
})
|
||||||
|
frameBars.push({
|
||||||
|
geo: new THREE.BoxGeometry(safeFt, innerH, fd),
|
||||||
|
pos: [-hw + safeFt / 2, 0, 0],
|
||||||
|
})
|
||||||
|
frameBars.push({
|
||||||
|
geo: new THREE.BoxGeometry(safeFt, innerH, fd),
|
||||||
|
pos: [hw - safeFt / 2, 0, 0],
|
||||||
|
})
|
||||||
|
|
||||||
|
const colDividerCount = cols - 1
|
||||||
|
const totalColDividerW = colDividerCount * safeDt
|
||||||
|
const paneAreaW = Math.max(0.01, innerW - totalColDividerW)
|
||||||
|
const paneW = paneAreaW / cols
|
||||||
|
|
||||||
|
for (let c = 1; c < cols; c++) {
|
||||||
|
const x = -innerW / 2 + c * paneW + (c - 0.5) * safeDt
|
||||||
|
frameBars.push({ geo: new THREE.BoxGeometry(safeDt, innerH, fd), pos: [x, 0, 0] })
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowDividerCount = rows - 1
|
||||||
|
const totalRowDividerH = rowDividerCount * safeDt
|
||||||
|
const paneAreaH = Math.max(0.01, innerH - totalRowDividerH)
|
||||||
|
const paneH = paneAreaH / rows
|
||||||
|
|
||||||
|
for (let r = 1; r < rows; r++) {
|
||||||
|
const y = -innerH / 2 + r * paneH + (r - 0.5) * safeDt
|
||||||
|
frameBars.push({ geo: new THREE.BoxGeometry(innerW, safeDt, fd), pos: [0, y, 0] })
|
||||||
|
}
|
||||||
|
|
||||||
|
const glassW = Math.max(0.01, paneAreaW / cols)
|
||||||
|
const glassH = Math.max(0.01, paneAreaH / rows)
|
||||||
|
const glassGeo = new THREE.BoxGeometry(glassW, glassH, 0.008)
|
||||||
|
|
||||||
|
for (let c = 0; c < cols; c++) {
|
||||||
|
const cx = -innerW / 2 + paneAreaW / cols / 2 + c * (paneAreaW / cols + safeDt)
|
||||||
|
for (let r = 0; r < rows; r++) {
|
||||||
|
const cy = -innerH / 2 + paneAreaH / rows / 2 + r * (paneAreaH / rows + safeDt)
|
||||||
|
glassPanes.push({ geo: glassGeo, pos: [cx, cy, 0] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { frameBars, glassPanes }
|
||||||
|
}
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import type { AnyNodeDefinition, Plugin } from '@pascal-app/core'
|
import type { AnyNodeDefinition, Plugin } from '@pascal-app/core'
|
||||||
|
import { boxVentDefinition } from './box-vent'
|
||||||
import { buildingDefinition } from './building'
|
import { buildingDefinition } from './building'
|
||||||
|
import { chimneyDefinition } from './chimney'
|
||||||
|
import { dormerDefinition } from './dormer'
|
||||||
|
import { ridgeVentDefinition } from './ridge-vent'
|
||||||
|
import { skylightDefinition } from './skylight'
|
||||||
|
import { solarPanelDefinition } from './solar-panel'
|
||||||
import { ceilingDefinition } from './ceiling'
|
import { ceilingDefinition } from './ceiling'
|
||||||
import { columnDefinition } from './column'
|
import { columnDefinition } from './column'
|
||||||
import { doorDefinition } from './door'
|
import { doorDefinition } from './door'
|
||||||
@@ -65,10 +71,23 @@ export const builtinPlugin: Plugin = {
|
|||||||
levelDefinition as unknown as AnyNodeDefinition,
|
levelDefinition as unknown as AnyNodeDefinition,
|
||||||
guideDefinition as unknown as AnyNodeDefinition,
|
guideDefinition as unknown as AnyNodeDefinition,
|
||||||
scanDefinition as unknown as AnyNodeDefinition,
|
scanDefinition as unknown as AnyNodeDefinition,
|
||||||
|
// Roof-mounted accessories (custom renderer + bespoke roof-event tool).
|
||||||
|
boxVentDefinition as unknown as AnyNodeDefinition,
|
||||||
|
ridgeVentDefinition as unknown as AnyNodeDefinition,
|
||||||
|
chimneyDefinition as unknown as AnyNodeDefinition,
|
||||||
|
solarPanelDefinition as unknown as AnyNodeDefinition,
|
||||||
|
skylightDefinition as unknown as AnyNodeDefinition,
|
||||||
|
dormerDefinition as unknown as AnyNodeDefinition,
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { boxVentDefinition } from './box-vent'
|
||||||
export { buildingDefinition } from './building'
|
export { buildingDefinition } from './building'
|
||||||
|
export { chimneyDefinition } from './chimney'
|
||||||
|
export { dormerDefinition } from './dormer'
|
||||||
|
export { ridgeVentDefinition } from './ridge-vent'
|
||||||
|
export { skylightDefinition } from './skylight'
|
||||||
|
export { solarPanelDefinition } from './solar-panel'
|
||||||
export { ceilingDefinition } from './ceiling'
|
export { ceilingDefinition } from './ceiling'
|
||||||
export { columnDefinition } from './column'
|
export { columnDefinition } from './column'
|
||||||
export { doorDefinition } from './door'
|
export { doorDefinition } from './door'
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import { buildRidgeVentGeometry } from '../geometry'
|
||||||
|
import { RidgeVentNode } from '../schema'
|
||||||
|
|
||||||
|
describe('buildRidgeVentGeometry', () => {
|
||||||
|
test('returns geometry with matching position / normal / uv counts', () => {
|
||||||
|
const geo = buildRidgeVentGeometry(RidgeVentNode.parse({}))
|
||||||
|
const p = geo.getAttribute('position').count
|
||||||
|
expect(p).toBeGreaterThan(0)
|
||||||
|
expect(geo.getAttribute('normal').count).toBe(p)
|
||||||
|
expect(geo.getAttribute('uv').count).toBe(p)
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
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
|
||||||
|
expect(withCaps).toBeGreaterThan(without)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('length scales the X bounds proportionally', () => {
|
||||||
|
const geo = buildRidgeVentGeometry(RidgeVentNode.parse({ length: 4, endCaps: false }))
|
||||||
|
const pos = geo.getAttribute('position').array as Float32Array
|
||||||
|
let maxX = -Infinity
|
||||||
|
let minX = Infinity
|
||||||
|
for (let i = 0; i < pos.length; i += 3) {
|
||||||
|
if (pos[i]! > maxX) maxX = pos[i]!
|
||||||
|
if (pos[i]! < minX) minX = pos[i]!
|
||||||
|
}
|
||||||
|
expect(maxX).toBeCloseTo(2)
|
||||||
|
expect(minX).toBeCloseTo(-2)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import { RidgeVentNode } from '../schema'
|
||||||
|
|
||||||
|
describe('RidgeVentNode schema', () => {
|
||||||
|
test('parses with defaults', () => {
|
||||||
|
const parsed = RidgeVentNode.parse({})
|
||||||
|
expect(parsed.type).toBe('ridge-vent')
|
||||||
|
expect(parsed.id).toMatch(/^rvent_/)
|
||||||
|
expect(parsed.length).toBe(2.0)
|
||||||
|
expect(parsed.width).toBe(0.3)
|
||||||
|
expect(parsed.height).toBe(0.08)
|
||||||
|
expect(parsed.style).toBe('standard')
|
||||||
|
expect(parsed.endCaps).toBe(true)
|
||||||
|
expect(parsed.position).toEqual([0, 0, 0])
|
||||||
|
expect(parsed.rotation).toBe(0)
|
||||||
|
expect(parsed.roofSegmentId).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('accepts each style', () => {
|
||||||
|
expect(RidgeVentNode.parse({ style: 'standard' }).style).toBe('standard')
|
||||||
|
expect(RidgeVentNode.parse({ style: 'shingled' }).style).toBe('shingled')
|
||||||
|
expect(RidgeVentNode.parse({ style: 'metal' }).style).toBe('metal')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects unknown style', () => {
|
||||||
|
expect(() => RidgeVentNode.parse({ style: 'foo' })).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('round-trips dimensions + binding', () => {
|
||||||
|
const parsed = RidgeVentNode.parse({
|
||||||
|
length: 3.5,
|
||||||
|
width: 0.4,
|
||||||
|
height: 0.1,
|
||||||
|
style: 'metal',
|
||||||
|
endCaps: false,
|
||||||
|
roofSegmentId: 'rseg_xyz',
|
||||||
|
position: [0.5, 0, 0],
|
||||||
|
rotation: Math.PI / 2,
|
||||||
|
})
|
||||||
|
expect(parsed.length).toBe(3.5)
|
||||||
|
expect(parsed.style).toBe('metal')
|
||||||
|
expect(parsed.endCaps).toBe(false)
|
||||||
|
expect(parsed.roofSegmentId).toBe('rseg_xyz')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('unique IDs across calls', () => {
|
||||||
|
expect(RidgeVentNode.parse({}).id).not.toBe(RidgeVentNode.parse({}).id)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { type NodeDefinition, RidgeVentNode as RidgeVentNodeSchema } from '@pascal-app/core'
|
||||||
|
import { ridgeVentParametrics } from './parametrics'
|
||||||
|
import { RidgeVentNode } from './schema'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ridge vent — a ventilation strip running along the ridge of a roof
|
||||||
|
* segment. Parented to a `roof-segment`; position is segment-local.
|
||||||
|
*
|
||||||
|
* Three-checkbox model — same shape as box-vent: custom `def.renderer`
|
||||||
|
* (parent segment transform lookup + live-transform follow), pure
|
||||||
|
* geometry builder shared with the placement preview + future tests,
|
||||||
|
* no animation or per-frame system.
|
||||||
|
*
|
||||||
|
* The placement tool snaps to the ridge (segment-local Z=0) wherever
|
||||||
|
* the cursor lands on a segment.
|
||||||
|
*/
|
||||||
|
export const ridgeVentDefinition: NodeDefinition<typeof RidgeVentNode> = {
|
||||||
|
kind: 'ridge-vent',
|
||||||
|
schemaVersion: 1,
|
||||||
|
schema: RidgeVentNode,
|
||||||
|
category: 'structure',
|
||||||
|
|
||||||
|
defaults: () => {
|
||||||
|
const stub = RidgeVentNodeSchema.parse({
|
||||||
|
id: 'rvent_default' as never,
|
||||||
|
type: 'ridge-vent',
|
||||||
|
})
|
||||||
|
const { id: _id, type: _type, ...rest } = stub
|
||||||
|
return rest
|
||||||
|
},
|
||||||
|
|
||||||
|
capabilities: {
|
||||||
|
selectable: { hitVolume: 'bbox' },
|
||||||
|
duplicable: true,
|
||||||
|
deletable: true,
|
||||||
|
// Mounts on a roof segment via `roofSegmentId`. Sits ON TOP of the
|
||||||
|
// ridge — no `buildCut`, just the dirty cascade so the parent
|
||||||
|
// roof's merged shell rebuilds when the vent moves / resizes.
|
||||||
|
roofAccessory: {},
|
||||||
|
},
|
||||||
|
|
||||||
|
parametrics: ridgeVentParametrics,
|
||||||
|
|
||||||
|
renderer: {
|
||||||
|
kind: 'parametric',
|
||||||
|
module: () => import('./renderer'),
|
||||||
|
},
|
||||||
|
|
||||||
|
preview: () => import('./preview'),
|
||||||
|
tool: () => import('./tool'),
|
||||||
|
affordanceTools: {
|
||||||
|
move: () => import('./move-tool'),
|
||||||
|
},
|
||||||
|
toolHints: [
|
||||||
|
{ key: 'Left click', label: 'Place ridge vent on roof' },
|
||||||
|
{ key: 'Esc', label: 'Cancel' },
|
||||||
|
],
|
||||||
|
|
||||||
|
presentation: {
|
||||||
|
label: 'Ridge Vent',
|
||||||
|
description: 'Ventilation strip running along the ridge of a roof segment.',
|
||||||
|
icon: { kind: 'url', src: '/icons/roof.png' },
|
||||||
|
paletteSection: 'structure',
|
||||||
|
paletteOrder: 121,
|
||||||
|
},
|
||||||
|
|
||||||
|
mcp: {
|
||||||
|
description:
|
||||||
|
'A ridge vent — three styles (standard curved cap / shingled / metal), optional end caps, length / width / height parametric.',
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,488 @@
|
|||||||
|
import type { RidgeVentNode } from '@pascal-app/core'
|
||||||
|
import * as THREE from 'three'
|
||||||
|
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
||||||
|
|
||||||
|
const ARC_SEGMENTS = 8
|
||||||
|
const SHELL_THICKNESS = 0.25
|
||||||
|
const SHINGLED_PEAK_SEGS = 3
|
||||||
|
const SHINGLED_TAB_SIZE = 0.3
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure builder for the ridge vent mesh. Three styles share a common
|
||||||
|
* cross-section approach: extrude a 2D profile (in the Y-Z plane)
|
||||||
|
* along the segment's X axis (ridge direction), then add optional
|
||||||
|
* end caps.
|
||||||
|
*
|
||||||
|
* - `standard`: smooth curved shell with offset inner surface
|
||||||
|
* - `shingled`: angular slopes meeting at a rounded peak with tab ridges
|
||||||
|
* - `metal`: angular bent-metal cap with drip-edge lips and a center bead
|
||||||
|
*
|
||||||
|
* Pure: no React, no scene access, no store mutation.
|
||||||
|
*/
|
||||||
|
export function buildRidgeVentGeometry(node: RidgeVentNode): THREE.BufferGeometry {
|
||||||
|
const halfLen = node.length / 2
|
||||||
|
const halfW = node.width / 2
|
||||||
|
const h = node.height
|
||||||
|
|
||||||
|
const pieces: THREE.BufferGeometry[] = []
|
||||||
|
|
||||||
|
if (node.style === 'metal') {
|
||||||
|
pieces.push(buildMetalProfile(halfLen, halfW, h))
|
||||||
|
} else if (node.style === 'shingled') {
|
||||||
|
pieces.push(buildShingledProfile(halfLen, halfW, h))
|
||||||
|
} else {
|
||||||
|
pieces.push(buildCurvedCapProfile(halfLen, halfW, h))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.endCaps) {
|
||||||
|
const cap =
|
||||||
|
node.style === 'metal'
|
||||||
|
? buildMetalEndCaps(halfLen, halfW, h)
|
||||||
|
: node.style === 'shingled'
|
||||||
|
? buildShingledEndCaps(halfLen, halfW, h)
|
||||||
|
: buildCurvedEndCaps(halfLen, halfW, h)
|
||||||
|
if (cap) pieces.push(cap)
|
||||||
|
}
|
||||||
|
|
||||||
|
return pieces.length === 1 ? pieces[0]! : (mergeGeometries(pieces, false) ?? pieces[0]!)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Standard curved cap ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
function buildCurvedCapProfile(
|
||||||
|
halfLen: number,
|
||||||
|
halfW: number,
|
||||||
|
h: number,
|
||||||
|
): THREE.BufferGeometry {
|
||||||
|
const positions: number[] = []
|
||||||
|
const normals: number[] = []
|
||||||
|
const uvs: number[] = []
|
||||||
|
const t = h * SHELL_THICKNESS
|
||||||
|
|
||||||
|
const outerPts: [number, number][] = []
|
||||||
|
for (let i = 0; i <= ARC_SEGMENTS; i++) {
|
||||||
|
const frac = i / ARC_SEGMENTS
|
||||||
|
const angle = Math.PI * frac
|
||||||
|
const z = -halfW + frac * (2 * halfW)
|
||||||
|
const y = h * Math.sin(angle)
|
||||||
|
outerPts.push([z, y])
|
||||||
|
}
|
||||||
|
const innerPts = offsetProfileInward(outerPts, t)
|
||||||
|
|
||||||
|
for (let i = 0; i < ARC_SEGMENTS; i++) {
|
||||||
|
const [oz0, oy0] = outerPts[i]!
|
||||||
|
const [oz1, oy1] = outerPts[i + 1]!
|
||||||
|
const [iz0, iy0] = innerPts[i]!
|
||||||
|
const [iz1, iy1] = innerPts[i + 1]!
|
||||||
|
|
||||||
|
const dz = oz1 - oz0
|
||||||
|
const dy = oy1 - oy0
|
||||||
|
const fLen = Math.sqrt(dz * dz + dy * dy) || 1
|
||||||
|
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, iy1, iz1], [halfLen, iy1, iz1],
|
||||||
|
[halfLen, iy0, iz0], [-halfLen, iy0, iz0],
|
||||||
|
[0, -fny, -fnz])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eave bottoms
|
||||||
|
for (const idx of [0, ARC_SEGMENTS]) {
|
||||||
|
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])
|
||||||
|
} else {
|
||||||
|
pushQuad(positions, normals, uvs,
|
||||||
|
[-halfLen, oy, oz], [halfLen, oy, oz],
|
||||||
|
[halfLen, iy, iz], [-halfLen, iy, iz],
|
||||||
|
[0, -1, 0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildBufferGeometry(positions, normals, uvs)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCurvedEndCaps(
|
||||||
|
halfLen: number,
|
||||||
|
halfW: number,
|
||||||
|
h: number,
|
||||||
|
): THREE.BufferGeometry | null {
|
||||||
|
const positions: number[] = []
|
||||||
|
const normals: number[] = []
|
||||||
|
const uvs: number[] = []
|
||||||
|
const t = h * SHELL_THICKNESS
|
||||||
|
|
||||||
|
const outerPts: [number, number][] = []
|
||||||
|
for (let i = 0; i <= ARC_SEGMENTS; i++) {
|
||||||
|
const frac = i / ARC_SEGMENTS
|
||||||
|
const angle = Math.PI * frac
|
||||||
|
outerPts.push([-halfW + frac * (2 * halfW), h * Math.sin(angle)])
|
||||||
|
}
|
||||||
|
const innerPts = offsetProfileInward(outerPts, t)
|
||||||
|
|
||||||
|
for (const sign of [-1, 1] as const) {
|
||||||
|
const x = sign * halfLen
|
||||||
|
for (let i = 0; i < ARC_SEGMENTS; i++) {
|
||||||
|
const a: [number, number, number] = [x, outerPts[i]![1], outerPts[i]![0]]
|
||||||
|
const b: [number, number, number] = [x, outerPts[i + 1]![1], outerPts[i + 1]![0]]
|
||||||
|
const c: [number, number, number] = [x, innerPts[i + 1]![1], innerPts[i + 1]![0]]
|
||||||
|
const d: [number, number, number] = [x, innerPts[i]![1], innerPts[i]![0]]
|
||||||
|
if (sign > 0) pushQuad(positions, normals, uvs, a, b, c, d, [sign, 0, 0])
|
||||||
|
else pushQuad(positions, normals, uvs, d, c, b, a, [sign, 0, 0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return positions.length === 0 ? null : buildBufferGeometry(positions, normals, uvs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Shingled profile ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
function shingledOuterPts(halfW: number, h: number): [number, number][] {
|
||||||
|
const peakR = halfW * 0.1
|
||||||
|
const slopeY = (h * (halfW - peakR)) / halfW
|
||||||
|
const pts: [number, number][] = [[-halfW, 0]]
|
||||||
|
for (let i = 0; i <= SHINGLED_PEAK_SEGS; i++) {
|
||||||
|
const frac = i / SHINGLED_PEAK_SEGS
|
||||||
|
const angle = Math.PI * (1 - frac)
|
||||||
|
pts.push([peakR * Math.cos(angle), slopeY + (h - slopeY) * Math.sin(angle)])
|
||||||
|
}
|
||||||
|
pts.push([halfW, 0])
|
||||||
|
return pts
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildShingledProfile(
|
||||||
|
halfLen: number,
|
||||||
|
halfW: number,
|
||||||
|
h: number,
|
||||||
|
): THREE.BufferGeometry {
|
||||||
|
const positions: number[] = []
|
||||||
|
const normals: number[] = []
|
||||||
|
const uvs: number[] = []
|
||||||
|
const t = h * SHELL_THICKNESS
|
||||||
|
|
||||||
|
const outerPts = shingledOuterPts(halfW, h)
|
||||||
|
const innerPts = offsetProfileInward(outerPts, t)
|
||||||
|
|
||||||
|
for (let i = 0; i < outerPts.length - 1; i++) {
|
||||||
|
const [oz0, oy0] = outerPts[i]!
|
||||||
|
const [oz1, oy1] = outerPts[i + 1]!
|
||||||
|
const [iz0, iy0] = innerPts[i]!
|
||||||
|
const [iz1, iy1] = innerPts[i + 1]!
|
||||||
|
|
||||||
|
const dz = oz1 - oz0
|
||||||
|
const dy = oy1 - oy0
|
||||||
|
const fLen = Math.sqrt(dz * dz + dy * dy) || 1
|
||||||
|
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, 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])
|
||||||
|
}
|
||||||
|
{
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab divider ridges along the length
|
||||||
|
const totalLen = halfLen * 2
|
||||||
|
const numTabs = Math.max(2, Math.round(totalLen / SHINGLED_TAB_SIZE))
|
||||||
|
const tabLen = totalLen / numTabs
|
||||||
|
const ridgeH = h * 0.06
|
||||||
|
const ridgeD = 0.006
|
||||||
|
|
||||||
|
for (let tab = 1; tab < numTabs; tab++) {
|
||||||
|
const x = -halfLen + tab * tabLen
|
||||||
|
for (let i = 0; i < outerPts.length - 1; i++) {
|
||||||
|
const [oz0, oy0] = outerPts[i]!
|
||||||
|
const [oz1, oy1] = outerPts[i + 1]!
|
||||||
|
const dz = oz1 - oz0
|
||||||
|
const dy = oy1 - oy0
|
||||||
|
const fLen = Math.sqrt(dz * dz + dy * dy) || 1
|
||||||
|
const fnz = -dy / fLen
|
||||||
|
const fny = dz / fLen
|
||||||
|
const r0y = oy0 + fny * ridgeH
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildBufferGeometry(positions, normals, uvs)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildShingledEndCaps(
|
||||||
|
halfLen: number,
|
||||||
|
halfW: number,
|
||||||
|
h: number,
|
||||||
|
): THREE.BufferGeometry | null {
|
||||||
|
const positions: number[] = []
|
||||||
|
const normals: number[] = []
|
||||||
|
const uvs: number[] = []
|
||||||
|
const t = h * SHELL_THICKNESS
|
||||||
|
|
||||||
|
const outerPts = shingledOuterPts(halfW, h)
|
||||||
|
const innerPts = offsetProfileInward(outerPts, t)
|
||||||
|
|
||||||
|
for (const sign of [-1, 1] as const) {
|
||||||
|
const x = sign * halfLen
|
||||||
|
for (let i = 0; i < outerPts.length - 1; i++) {
|
||||||
|
const a: [number, number, number] = [x, outerPts[i]![1], outerPts[i]![0]]
|
||||||
|
const b: [number, number, number] = [x, outerPts[i + 1]![1], outerPts[i + 1]![0]]
|
||||||
|
const c: [number, number, number] = [x, innerPts[i + 1]![1], innerPts[i + 1]![0]]
|
||||||
|
const d: [number, number, number] = [x, innerPts[i]![1], innerPts[i]![0]]
|
||||||
|
if (sign > 0) pushQuad(positions, normals, uvs, a, b, c, d, [sign, 0, 0])
|
||||||
|
else pushQuad(positions, normals, uvs, d, c, b, a, [sign, 0, 0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return positions.length === 0 ? null : buildBufferGeometry(positions, normals, uvs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Metal profile ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bent-sheet-metal ridge cap cross-section. Real metal vents read as a
|
||||||
|
* smooth arched cap riding above two flat mounting flanges — not the
|
||||||
|
* old angular peak + bead, which looked like a stamped novelty. Profile:
|
||||||
|
*
|
||||||
|
* flange ─┐ ┌─ flange
|
||||||
|
* │ ◜╶───── rounded ridge cap ─────╶◝ │
|
||||||
|
* │ ╱ ╲ │
|
||||||
|
* └─╯ ╰────┘
|
||||||
|
*
|
||||||
|
* Built from outer points (Z, Y); the inner shell is offset inward so the
|
||||||
|
* cap reads as a real folded-metal thickness instead of paper-thin.
|
||||||
|
*/
|
||||||
|
function metalProfile(halfW: number, h: number, t: number) {
|
||||||
|
// Horizontal mounting flange that hugs the shingles on each side. Wide
|
||||||
|
// enough to read as a real screw-down tab, not a sliver.
|
||||||
|
const flangeW = halfW * 0.22
|
||||||
|
// Where the arched cap takes off from the flange tip — gentle rise so
|
||||||
|
// the corner reads as a soft fold instead of a hard kink.
|
||||||
|
const liftH = h * 0.12
|
||||||
|
const liftDZ = halfW * 0.04
|
||||||
|
// Span and height of the rounded cap. Span stays narrower than the
|
||||||
|
// overall width so the cap "rides" on the flanges rather than swallow-
|
||||||
|
// ing them.
|
||||||
|
const capHalfSpan = halfW * 0.7
|
||||||
|
const capPeakY = h
|
||||||
|
const capStartY = h * 0.45
|
||||||
|
const capSegs = 12
|
||||||
|
|
||||||
|
const outer: [number, number][] = []
|
||||||
|
// Left flange — flat horizontal tab.
|
||||||
|
outer.push([-halfW, 0])
|
||||||
|
outer.push([-halfW + flangeW, 0])
|
||||||
|
// Soft fold up to the cap's starting shoulder.
|
||||||
|
outer.push([-halfW + flangeW + liftDZ, liftH])
|
||||||
|
outer.push([-capHalfSpan, capStartY])
|
||||||
|
// Rounded ridge: half-sine from left shoulder over the top to right
|
||||||
|
// shoulder. Using sin() (not cos+sin sphere math) keeps the cap's
|
||||||
|
// tangents continuous with the slope below — no visible kinks.
|
||||||
|
for (let i = 1; i < capSegs; i++) {
|
||||||
|
const frac = i / capSegs
|
||||||
|
const z = -capHalfSpan + frac * (2 * capHalfSpan)
|
||||||
|
const y = capStartY + (capPeakY - capStartY) * Math.sin(frac * Math.PI)
|
||||||
|
outer.push([z, y])
|
||||||
|
}
|
||||||
|
// Mirror down the right side.
|
||||||
|
outer.push([capHalfSpan, capStartY])
|
||||||
|
outer.push([halfW - flangeW - liftDZ, liftH])
|
||||||
|
outer.push([halfW - flangeW, 0])
|
||||||
|
outer.push([halfW, 0])
|
||||||
|
|
||||||
|
const inner = offsetProfileInward(outer, t)
|
||||||
|
return { outer, inner }
|
||||||
|
}
|
||||||
|
|
||||||
|
function segNormal(z0: number, y0: number, z1: number, y1: number): number[] {
|
||||||
|
const dz = z1 - z0
|
||||||
|
const dy = y1 - y0
|
||||||
|
const len = Math.sqrt(dz * dz + dy * dy) || 1
|
||||||
|
return [0, dz / len, -dy / len]
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMetalProfile(
|
||||||
|
halfLen: number,
|
||||||
|
halfW: number,
|
||||||
|
h: number,
|
||||||
|
): THREE.BufferGeometry {
|
||||||
|
const positions: number[] = []
|
||||||
|
const normals: number[] = []
|
||||||
|
const uvs: number[] = []
|
||||||
|
const t = h * SHELL_THICKNESS
|
||||||
|
const { outer, inner } = metalProfile(halfW, h, t)
|
||||||
|
|
||||||
|
for (let i = 0; i < outer.length - 1; i++) {
|
||||||
|
const [oz0, oy0] = outer[i]!
|
||||||
|
const [oz1, oy1] = outer[i + 1]!
|
||||||
|
const [iz0, iy0] = inner[i]!
|
||||||
|
const [iz1, iy1] = inner[i + 1]!
|
||||||
|
|
||||||
|
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, 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])
|
||||||
|
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])
|
||||||
|
|
||||||
|
return buildBufferGeometry(positions, normals, uvs)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMetalEndCaps(
|
||||||
|
halfLen: number,
|
||||||
|
halfW: number,
|
||||||
|
h: number,
|
||||||
|
): THREE.BufferGeometry | null {
|
||||||
|
const positions: number[] = []
|
||||||
|
const normals: number[] = []
|
||||||
|
const uvs: number[] = []
|
||||||
|
const t = h * SHELL_THICKNESS
|
||||||
|
const { outer, inner } = metalProfile(halfW, h, t)
|
||||||
|
|
||||||
|
for (const sign of [-1, 1] as const) {
|
||||||
|
const x = sign * halfLen
|
||||||
|
|
||||||
|
for (let i = 0; i < outer.length - 1; i++) {
|
||||||
|
const a: [number, number, number] = [x, outer[i]![1], outer[i]![0]]
|
||||||
|
const b: [number, number, number] = [x, outer[i + 1]![1], outer[i + 1]![0]]
|
||||||
|
const c: [number, number, number] = [x, inner[i + 1]![1], inner[i + 1]![0]]
|
||||||
|
const d: [number, number, number] = [x, inner[i]![1], inner[i]![0]]
|
||||||
|
if (sign > 0) pushQuad(positions, normals, uvs, a, b, c, d, [sign, 0, 0])
|
||||||
|
else pushQuad(positions, normals, uvs, d, c, b, a, [sign, 0, 0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return positions.length === 0 ? null : buildBufferGeometry(positions, normals, uvs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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]!
|
||||||
|
let dz: number
|
||||||
|
let dy: number
|
||||||
|
if (i === 0) {
|
||||||
|
dz = pts[1]![0] - z
|
||||||
|
dy = pts[1]![1] - y
|
||||||
|
} else if (i === pts.length - 1) {
|
||||||
|
dz = z - pts[i - 1]![0]
|
||||||
|
dy = y - pts[i - 1]![1]
|
||||||
|
} else {
|
||||||
|
dz = pts[i + 1]![0] - pts[i - 1]![0]
|
||||||
|
dy = pts[i + 1]![1] - pts[i - 1]![1]
|
||||||
|
}
|
||||||
|
const len = Math.sqrt(dz * dz + dy * dy) || 1
|
||||||
|
const nz = dy / len
|
||||||
|
const ny = -dz / len
|
||||||
|
result.push([z + nz * t, y + ny * t])
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBufferGeometry(
|
||||||
|
positions: number[],
|
||||||
|
normals: number[],
|
||||||
|
uvs: number[],
|
||||||
|
): THREE.BufferGeometry {
|
||||||
|
const geo = new THREE.BufferGeometry()
|
||||||
|
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
|
||||||
|
geo.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3))
|
||||||
|
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
|
||||||
|
return geo
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushQuad(
|
||||||
|
positions: number[],
|
||||||
|
normals: number[],
|
||||||
|
uvs: number[],
|
||||||
|
a: number[] | readonly number[],
|
||||||
|
b: number[] | readonly number[],
|
||||||
|
c: number[] | readonly number[],
|
||||||
|
d: number[] | readonly number[],
|
||||||
|
n: number[] | readonly number[],
|
||||||
|
) {
|
||||||
|
// Dimension-based planar UVs: U follows |b-a|, V follows |d-a|, so
|
||||||
|
// textures tile at world scale across the ridge length, the arched
|
||||||
|
// shell, and the end caps. Hardcoded 0..1 UVs stretched each face
|
||||||
|
// independently — a 2m ridge tile looked the same as a 4cm lip.
|
||||||
|
const abx = b[0]! - a[0]!
|
||||||
|
const aby = b[1]! - a[1]!
|
||||||
|
const abz = b[2]! - a[2]!
|
||||||
|
const adx = d[0]! - a[0]!
|
||||||
|
const ady = d[1]! - a[1]!
|
||||||
|
const adz = d[2]! - a[2]!
|
||||||
|
const u = Math.sqrt(abx * abx + aby * aby + abz * abz)
|
||||||
|
const v = Math.sqrt(adx * adx + ady * ady + adz * adz)
|
||||||
|
|
||||||
|
// Winding is (a, c, b) + (a, d, c) so the triangle face direction
|
||||||
|
// matches the stored normal — same fix as box-vent's pushQuad.
|
||||||
|
positions.push(a[0]!, a[1]!, a[2]!, c[0]!, c[1]!, c[2]!, b[0]!, b[1]!, b[2]!)
|
||||||
|
normals.push(n[0]!, n[1]!, n[2]!, n[0]!, n[1]!, n[2]!, n[0]!, n[1]!, n[2]!)
|
||||||
|
uvs.push(0, 0, u, v, u, 0)
|
||||||
|
positions.push(a[0]!, a[1]!, a[2]!, d[0]!, d[1]!, d[2]!, c[0]!, c[1]!, c[2]!)
|
||||||
|
normals.push(n[0]!, n[1]!, n[2]!, n[0]!, n[1]!, n[2]!, n[0]!, n[1]!, n[2]!)
|
||||||
|
uvs.push(0, 0, 0, v, u, v)
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { ridgeVentDefinition } from './definition'
|
||||||
|
export { buildRidgeVentGeometry } from './geometry'
|
||||||
|
export { RidgeVentNode } from './schema'
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import {
|
||||||
|
type AnyNodeId,
|
||||||
|
emitter,
|
||||||
|
type RidgeVentNode,
|
||||||
|
type RoofEvent,
|
||||||
|
type RoofNode,
|
||||||
|
type RoofSegmentNode,
|
||||||
|
sceneRegistry,
|
||||||
|
useScene,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import * as THREE from 'three'
|
||||||
|
import { resolveRoofSegmentHit } from '../roof/segment-hit'
|
||||||
|
import RidgeVentPreview from './preview'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ridge-vent move tool. Mirrors the box-vent move flow — ghost follows
|
||||||
|
* the cursor over any roof segment, click commits the new position +
|
||||||
|
* parent segment in one undoable step. The ridge sits along the ridge
|
||||||
|
* line, so we don't tilt the preview by the segment slope (the renderer
|
||||||
|
* places the vent on the peak); we just yaw it with the roof + segment.
|
||||||
|
*/
|
||||||
|
export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
|
||||||
|
const exitMoveMode = useCallback(() => {
|
||||||
|
useEditor.getState().setMovingNode(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const [previewPos, setPreviewPos] = useState<[number, number, number] | null>(null)
|
||||||
|
const [previewYaw, setPreviewYaw] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
useScene.temporal.getState().pause()
|
||||||
|
|
||||||
|
const original = {
|
||||||
|
position: [...node.position] as [number, number, number],
|
||||||
|
rotation: node.rotation ?? 0,
|
||||||
|
roofSegmentId: node.roofSegmentId,
|
||||||
|
parentId: node.parentId,
|
||||||
|
metadata: node.metadata,
|
||||||
|
}
|
||||||
|
const meta =
|
||||||
|
typeof node.metadata === 'object' && node.metadata !== null
|
||||||
|
? (node.metadata as Record<string, unknown>)
|
||||||
|
: {}
|
||||||
|
const isNew = !!meta.isNew
|
||||||
|
|
||||||
|
const ventObj = sceneRegistry.nodes.get(node.id)
|
||||||
|
if (ventObj) ventObj.visible = false
|
||||||
|
|
||||||
|
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) return [wx, wy, wz]
|
||||||
|
const v = new THREE.Vector3(wx, wy, wz)
|
||||||
|
buildingObj.worldToLocal(v)
|
||||||
|
return [v.x, v.y, v.z]
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastSnap: [number, number] | null = null
|
||||||
|
|
||||||
|
const updatePreview = (event: RoofEvent) => {
|
||||||
|
const wx = event.position[0]
|
||||||
|
const wy = event.position[1]
|
||||||
|
const wz = event.position[2]
|
||||||
|
|
||||||
|
const sx = Math.round(wx * 20) / 20
|
||||||
|
const sz = Math.round(wz * 20) / 20
|
||||||
|
if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) {
|
||||||
|
triggerSFX('sfx:grid-snap')
|
||||||
|
lastSnap = [sx, sz]
|
||||||
|
}
|
||||||
|
|
||||||
|
const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz)
|
||||||
|
if (!hit) return
|
||||||
|
|
||||||
|
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0))
|
||||||
|
setPreviewPos(worldToBuildingLocal(wx, wy, wz))
|
||||||
|
event.stopPropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onRoofClick = (event: RoofEvent) => {
|
||||||
|
const hit = resolveRoofSegmentHit(
|
||||||
|
event.node as RoofNode,
|
||||||
|
event.position[0],
|
||||||
|
event.position[1],
|
||||||
|
event.position[2],
|
||||||
|
)
|
||||||
|
if (!hit) return
|
||||||
|
const targetSegmentId = hit.segment.id as AnyNodeId
|
||||||
|
const st = useScene.getState()
|
||||||
|
|
||||||
|
const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined
|
||||||
|
if (prevSegmentId && prevSegmentId !== targetSegmentId) {
|
||||||
|
const oldSeg = st.nodes[prevSegmentId] as RoofSegmentNode | undefined
|
||||||
|
if (oldSeg) {
|
||||||
|
st.updateNode(prevSegmentId, {
|
||||||
|
children: (oldSeg.children ?? []).filter((id) => id !== node.id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const newSeg = st.nodes[targetSegmentId] as RoofSegmentNode | undefined
|
||||||
|
if (newSeg && !(newSeg.children ?? []).includes(node.id)) {
|
||||||
|
st.updateNode(targetSegmentId, {
|
||||||
|
children: [...(newSeg.children ?? []), node.id],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
st.dirtyNodes.add(prevSegmentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
useScene.temporal.getState().resume()
|
||||||
|
st.updateNode(node.id as AnyNodeId, {
|
||||||
|
roofSegmentId: targetSegmentId,
|
||||||
|
parentId: targetSegmentId,
|
||||||
|
position: [hit.localX, hit.localY, hit.localZ],
|
||||||
|
rotation: original.rotation,
|
||||||
|
visible: true,
|
||||||
|
metadata: {},
|
||||||
|
})
|
||||||
|
useScene.temporal.getState().pause()
|
||||||
|
|
||||||
|
st.dirtyNodes.add(targetSegmentId)
|
||||||
|
st.dirtyNodes.add(node.id as AnyNodeId)
|
||||||
|
|
||||||
|
const obj = sceneRegistry.nodes.get(node.id)
|
||||||
|
if (obj) obj.visible = true
|
||||||
|
|
||||||
|
triggerSFX('sfx:item-place')
|
||||||
|
exitMoveMode()
|
||||||
|
event.stopPropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCancel = () => {
|
||||||
|
if (isNew) {
|
||||||
|
const parentId = original.roofSegmentId as AnyNodeId | undefined
|
||||||
|
if (parentId) {
|
||||||
|
const parent = useScene.getState().nodes[parentId] as
|
||||||
|
| RoofSegmentNode
|
||||||
|
| undefined
|
||||||
|
if (parent) {
|
||||||
|
useScene.getState().updateNode(parentId, {
|
||||||
|
children: (parent.children ?? []).filter((id) => id !== node.id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
useScene.getState().deleteNode(node.id as AnyNodeId)
|
||||||
|
useScene.temporal.getState().resume()
|
||||||
|
markToolCancelConsumed()
|
||||||
|
exitMoveMode()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
useScene.getState().updateNode(node.id as AnyNodeId, {
|
||||||
|
position: original.position,
|
||||||
|
rotation: original.rotation,
|
||||||
|
roofSegmentId: original.roofSegmentId as AnyNodeId | undefined,
|
||||||
|
parentId: original.parentId as AnyNodeId | undefined,
|
||||||
|
metadata: original.metadata,
|
||||||
|
})
|
||||||
|
if (original.roofSegmentId) {
|
||||||
|
useScene.getState().dirtyNodes.add(original.roofSegmentId as AnyNodeId)
|
||||||
|
}
|
||||||
|
const obj = sceneRegistry.nodes.get(node.id)
|
||||||
|
if (obj) obj.visible = true
|
||||||
|
|
||||||
|
useScene.temporal.getState().resume()
|
||||||
|
markToolCancelConsumed()
|
||||||
|
exitMoveMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
emitter.on('roof:move', updatePreview)
|
||||||
|
emitter.on('roof:enter', updatePreview)
|
||||||
|
emitter.on('roof:click', onRoofClick)
|
||||||
|
emitter.on('tool:cancel', onCancel)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
emitter.off('roof:move', updatePreview)
|
||||||
|
emitter.off('roof:enter', updatePreview)
|
||||||
|
emitter.off('roof:click', onRoofClick)
|
||||||
|
emitter.off('tool:cancel', onCancel)
|
||||||
|
|
||||||
|
const obj = sceneRegistry.nodes.get(node.id)
|
||||||
|
if (obj) obj.visible = true
|
||||||
|
useScene.temporal.getState().resume()
|
||||||
|
}
|
||||||
|
}, [exitMoveMode, node])
|
||||||
|
|
||||||
|
if (!previewPos) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group position={previewPos}>
|
||||||
|
<group rotation-y={previewYaw}>
|
||||||
|
<RidgeVentPreview node={node} />
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import {
|
||||||
|
type AnyNode,
|
||||||
|
type AnyNodeId,
|
||||||
|
getActiveRoofHeight,
|
||||||
|
RidgeVentNode as RidgeVentSchema,
|
||||||
|
type RoofSegmentNode,
|
||||||
|
useScene,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import {
|
||||||
|
ActionButton,
|
||||||
|
ActionGroup,
|
||||||
|
PanelSection,
|
||||||
|
PanelWrapper,
|
||||||
|
SegmentedControl,
|
||||||
|
SliderControl,
|
||||||
|
triggerSFX,
|
||||||
|
useEditor,
|
||||||
|
} from '@pascal-app/editor'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { Copy, Move, Trash2 } from 'lucide-react'
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
import type { RidgeVentNode } from './schema'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inspector panel for a placed ridge vent. Same structure as box-vent's
|
||||||
|
* panel — sliders for style / dimensions / position + Move / Duplicate /
|
||||||
|
* Delete actions that route through the kind-owned ghost-drag flow.
|
||||||
|
*/
|
||||||
|
export default function RidgeVentPanel() {
|
||||||
|
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||||
|
const setSelection = useViewer((s) => s.setSelection)
|
||||||
|
const updateNode = useScene((s) => s.updateNode)
|
||||||
|
const deleteNode = useScene((s) => s.deleteNode)
|
||||||
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||||
|
|
||||||
|
const node = useScene((s) =>
|
||||||
|
selectedId ? (s.nodes[selectedId as AnyNode['id']] as RidgeVentNode | undefined) : undefined,
|
||||||
|
)
|
||||||
|
|
||||||
|
const segment = useScene((s) =>
|
||||||
|
node?.roofSegmentId
|
||||||
|
? (s.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined)
|
||||||
|
: undefined,
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleUpdate = useCallback(
|
||||||
|
(updates: Partial<RidgeVentNode>) => {
|
||||||
|
if (!selectedId) return
|
||||||
|
updateNode(selectedId as AnyNode['id'], updates)
|
||||||
|
},
|
||||||
|
[selectedId, updateNode],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setSelection({ selectedIds: [] })
|
||||||
|
}, [setSelection])
|
||||||
|
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
if (node?.roofSegmentId) {
|
||||||
|
setSelection({ selectedIds: [node.roofSegmentId as AnyNode['id']] })
|
||||||
|
}
|
||||||
|
}, [node?.roofSegmentId, setSelection])
|
||||||
|
|
||||||
|
const handleMove = useCallback(() => {
|
||||||
|
if (!node) return
|
||||||
|
triggerSFX('sfx:item-pick')
|
||||||
|
// Type-union escape — see box-vent panel.
|
||||||
|
setMovingNode(node as never)
|
||||||
|
setSelection({ selectedIds: [] })
|
||||||
|
}, [node, setMovingNode, setSelection])
|
||||||
|
|
||||||
|
const handleDuplicate = useCallback(() => {
|
||||||
|
if (!node) return
|
||||||
|
triggerSFX('sfx:item-pick')
|
||||||
|
const parentId = node.roofSegmentId as AnyNodeId | undefined
|
||||||
|
if (!parentId) return
|
||||||
|
|
||||||
|
const state = useScene.getState()
|
||||||
|
const meta =
|
||||||
|
typeof node.metadata === 'object' && node.metadata !== null
|
||||||
|
? (node.metadata as Record<string, unknown>)
|
||||||
|
: {}
|
||||||
|
const cloneInput = {
|
||||||
|
...node,
|
||||||
|
id: undefined,
|
||||||
|
metadata: { ...meta, isNew: true },
|
||||||
|
} as Record<string, unknown>
|
||||||
|
const cloned = RidgeVentSchema.parse(cloneInput) as RidgeVentNode
|
||||||
|
|
||||||
|
state.createNode(cloned, parentId)
|
||||||
|
state.dirtyNodes.add(parentId)
|
||||||
|
setMovingNode(cloned as never)
|
||||||
|
setSelection({ selectedIds: [] })
|
||||||
|
}, [node, setMovingNode, setSelection])
|
||||||
|
|
||||||
|
const handleDelete = useCallback(() => {
|
||||||
|
if (!(selectedId && node)) return
|
||||||
|
triggerSFX('sfx:item-delete')
|
||||||
|
const segmentId = node.roofSegmentId
|
||||||
|
if (segmentId) {
|
||||||
|
const state = useScene.getState()
|
||||||
|
const seg = state.nodes[segmentId as AnyNodeId] as RoofSegmentNode | undefined
|
||||||
|
if (seg) {
|
||||||
|
state.updateNode(segmentId as AnyNode['id'], {
|
||||||
|
children: (seg.children ?? []).filter((id) => id !== selectedId),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deleteNode(selectedId as AnyNodeId)
|
||||||
|
if (segmentId) {
|
||||||
|
useScene.getState().dirtyNodes.add(segmentId as AnyNodeId)
|
||||||
|
setSelection({ selectedIds: [segmentId as AnyNode['id']] })
|
||||||
|
} else {
|
||||||
|
setSelection({ selectedIds: [] })
|
||||||
|
}
|
||||||
|
}, [selectedId, node, deleteNode, setSelection])
|
||||||
|
|
||||||
|
if (!(node && node.type === 'ridge-vent' && selectedId)) return null
|
||||||
|
|
||||||
|
// Ridge runs along segment-X axis, so the length slider's max ties to
|
||||||
|
// segment.width and the across-ridge X-position to the same span. Z
|
||||||
|
// is the across-slope position — for ridge vents this should stay
|
||||||
|
// near zero (the ridge line), so clamp it to a narrow window around
|
||||||
|
// the segment's center.
|
||||||
|
const halfW = Math.round(((segment?.width ?? 10) / 2) * 100) / 100
|
||||||
|
const halfD = Math.round(((segment?.depth ?? 10) / 2) * 100) / 100
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PanelWrapper
|
||||||
|
icon="/icons/roof.png"
|
||||||
|
onBack={node.roofSegmentId ? handleBack : undefined}
|
||||||
|
onClose={handleClose}
|
||||||
|
title={node.name || 'Ridge Vent'}
|
||||||
|
width={300}
|
||||||
|
>
|
||||||
|
<PanelSection title="Style">
|
||||||
|
<SegmentedControl
|
||||||
|
onChange={(v) => handleUpdate({ style: v as RidgeVentNode['style'] })}
|
||||||
|
options={[
|
||||||
|
{ label: 'Standard', value: 'standard' },
|
||||||
|
{ label: 'Shingled', value: 'shingled' },
|
||||||
|
{ label: 'Flanged', value: 'metal' },
|
||||||
|
]}
|
||||||
|
value={node.style ?? 'standard'}
|
||||||
|
/>
|
||||||
|
<SegmentedControl
|
||||||
|
onChange={(v) => handleUpdate({ endCaps: v === 'yes' })}
|
||||||
|
options={[
|
||||||
|
{ label: 'End Caps', value: 'yes' },
|
||||||
|
{ label: 'Open', value: 'no' },
|
||||||
|
]}
|
||||||
|
value={(node.endCaps ?? true) ? 'yes' : 'no'}
|
||||||
|
/>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection title="Dimensions">
|
||||||
|
<SliderControl
|
||||||
|
label="Length"
|
||||||
|
max={8}
|
||||||
|
min={0.5}
|
||||||
|
onChange={(v) => handleUpdate({ length: v })}
|
||||||
|
onCommit={(v) => handleUpdate({ length: v })}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.05}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round(node.length * 100) / 100}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Width"
|
||||||
|
max={0.6}
|
||||||
|
min={0.1}
|
||||||
|
onChange={(v) => handleUpdate({ width: v })}
|
||||||
|
onCommit={(v) => handleUpdate({ width: v })}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.01}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round(node.width * 100) / 100}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Height"
|
||||||
|
max={0.2}
|
||||||
|
min={0.03}
|
||||||
|
onChange={(v) => handleUpdate({ height: v })}
|
||||||
|
onCommit={(v) => handleUpdate({ height: v })}
|
||||||
|
precision={3}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.005}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round(node.height * 1000) / 1000}
|
||||||
|
/>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection title="Position">
|
||||||
|
<SliderControl
|
||||||
|
label="X"
|
||||||
|
max={halfW}
|
||||||
|
min={-halfW}
|
||||||
|
onChange={(v) =>
|
||||||
|
handleUpdate({
|
||||||
|
position: [v, node.position[1] ?? 0, node.position[2] ?? 0],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onCommit={(v) =>
|
||||||
|
handleUpdate({
|
||||||
|
position: [v, node.position[1] ?? 0, node.position[2] ?? 0],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.05}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round((node.position[0] ?? 0) * 100) / 100}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Y"
|
||||||
|
max={Math.max(
|
||||||
|
(segment?.wallHeight ?? 3) + (segment ? getActiveRoofHeight(segment) : 3) + 2,
|
||||||
|
(node.position[1] ?? 0) + 0.1,
|
||||||
|
)}
|
||||||
|
min={Math.min(0, (node.position[1] ?? 0) - 0.5)}
|
||||||
|
onChange={(v) =>
|
||||||
|
handleUpdate({
|
||||||
|
position: [node.position[0] ?? 0, v, node.position[2] ?? 0],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onCommit={(v) =>
|
||||||
|
handleUpdate({
|
||||||
|
position: [node.position[0] ?? 0, v, node.position[2] ?? 0],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.05}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round((node.position[1] ?? 0) * 100) / 100}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Z"
|
||||||
|
max={halfD}
|
||||||
|
min={-halfD}
|
||||||
|
onChange={(v) =>
|
||||||
|
handleUpdate({
|
||||||
|
position: [node.position[0] ?? 0, node.position[1] ?? 0, v],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onCommit={(v) =>
|
||||||
|
handleUpdate({
|
||||||
|
position: [node.position[0] ?? 0, node.position[1] ?? 0, v],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
precision={2}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={0.05}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round((node.position[2] ?? 0) * 100) / 100}
|
||||||
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Rotation"
|
||||||
|
max={180}
|
||||||
|
min={-180}
|
||||||
|
onChange={(deg) => handleUpdate({ rotation: (deg * Math.PI) / 180 })}
|
||||||
|
onCommit={(deg) => handleUpdate({ rotation: (deg * Math.PI) / 180 })}
|
||||||
|
precision={0}
|
||||||
|
restoreOnCommit={false}
|
||||||
|
step={1}
|
||||||
|
unit="°"
|
||||||
|
value={Math.round(((node.rotation ?? 0) * 180) / Math.PI)}
|
||||||
|
/>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection title="Actions">
|
||||||
|
<ActionGroup>
|
||||||
|
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||||
|
<ActionButton
|
||||||
|
icon={<Copy className="h-3.5 w-3.5" />}
|
||||||
|
label="Duplicate"
|
||||||
|
onClick={handleDuplicate}
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
className="hover:bg-red-500/20"
|
||||||
|
icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
|
||||||
|
label="Delete"
|
||||||
|
onClick={handleDelete}
|
||||||
|
/>
|
||||||
|
</ActionGroup>
|
||||||
|
</PanelSection>
|
||||||
|
</PanelWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { ParametricDescriptor } from '@pascal-app/core'
|
||||||
|
import type { RidgeVentNode } from './schema'
|
||||||
|
|
||||||
|
export const ridgeVentParametrics: ParametricDescriptor<RidgeVentNode> = {
|
||||||
|
// Custom panel exposes Position sliders + Move/Duplicate actions
|
||||||
|
// alongside the style/dimensions controls. See box-vent's parametrics
|
||||||
|
// for the same pattern.
|
||||||
|
customPanel: () => import('./panel'),
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
label: 'Style',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'style',
|
||||||
|
kind: 'enum',
|
||||||
|
options: ['standard', 'shingled', 'metal'],
|
||||||
|
display: 'segmented',
|
||||||
|
},
|
||||||
|
{ key: 'endCaps', kind: 'boolean' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Dimensions',
|
||||||
|
fields: [
|
||||||
|
{ key: 'length', kind: 'number', unit: 'm', min: 0.5, max: 8, step: 0.05 },
|
||||||
|
{ key: 'width', kind: 'number', unit: 'm', min: 0.1, max: 0.6, step: 0.01 },
|
||||||
|
{ key: 'height', kind: 'number', unit: 'm', min: 0.03, max: 0.2, step: 0.005 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user