From d487684c16e3b8a4b8c606b0b7ab9d963643e655 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 22 Oct 2026 16:35:11 +0530 Subject: [PATCH 1/6] Improve build search, HVAC connectivity, and roof accessories --- apps/editor/components/build-tab.tsx | 76 ++++- apps/editor/lib/build-palette.ts | 19 +- apps/editor/lib/build-search.test.ts | 49 ++++ apps/editor/lib/build-search.ts | 36 +++ apps/editor/public/icons/box-vent/box.webp | Bin 0 -> 9738 bytes apps/editor/public/icons/box-vent/cap.webp | Bin 0 -> 10048 bytes apps/editor/public/icons/box-vent/dome.webp | Bin 0 -> 9062 bytes .../editor/public/icons/canopy/butterfly.webp | Bin 0 -> 10200 bytes apps/editor/public/icons/canopy/gable.webp | Bin 0 -> 10010 bytes apps/editor/public/icons/canopy/mono.webp | Bin 0 -> 9698 bytes apps/editor/public/icons/chimney/brick.webp | Bin 0 -> 7024 bytes apps/editor/public/icons/chimney/modern.webp | Bin 0 -> 5796 bytes apps/editor/public/icons/chimney/round.webp | Bin 0 -> 5832 bytes apps/editor/public/icons/cupola/dome.webp | Bin 0 -> 13484 bytes apps/editor/public/icons/cupola/pyramid.webp | Bin 0 -> 12356 bytes apps/editor/public/icons/dormer/dutch.webp | Bin 0 -> 11140 bytes apps/editor/public/icons/dormer/flat.webp | Bin 0 -> 9512 bytes apps/editor/public/icons/dormer/gable.webp | Bin 0 -> 10488 bytes apps/editor/public/icons/dormer/gambrel.webp | Bin 0 -> 12620 bytes apps/editor/public/icons/dormer/hip.webp | Bin 0 -> 10406 bytes apps/editor/public/icons/dormer/mansard.webp | Bin 0 -> 8626 bytes apps/editor/public/icons/dormer/shed.webp | Bin 0 -> 7752 bytes .../public/icons/downspout/kickout.webp | Bin 0 -> 4826 bytes apps/editor/public/icons/downspout/rect.webp | Bin 0 -> 4844 bytes apps/editor/public/icons/downspout/round.webp | Bin 0 -> 4100 bytes .../editor/public/icons/downspout/splash.webp | Bin 0 -> 6474 bytes .../public/icons/downspout/straight.webp | Bin 0 -> 5308 bytes .../public/icons/eyebrow-vent/half-round.webp | Bin 0 -> 8478 bytes .../public/icons/eyebrow-vent/scoop.webp | Bin 0 -> 6434 bytes .../public/icons/eyebrow-vent/slant-box.webp | Bin 0 -> 9472 bytes apps/editor/public/icons/gutter/box.webp | Bin 0 -> 8622 bytes .../public/icons/gutter/half-round.webp | Bin 0 -> 8138 bytes apps/editor/public/icons/gutter/k-style.webp | Bin 0 -> 8628 bytes .../editor/public/icons/ridge-vent/metal.webp | Bin 0 -> 8198 bytes .../public/icons/ridge-vent/shingled.webp | Bin 0 -> 9568 bytes .../public/icons/ridge-vent/standard.webp | Bin 0 -> 7212 bytes apps/editor/public/icons/skylight/flat.webp | Bin 0 -> 10200 bytes .../editor/public/icons/skylight/lantern.webp | Bin 0 -> 11678 bytes .../editor/public/icons/skylight/opening.webp | Bin 0 -> 15426 bytes .../editor/public/icons/skylight/sliding.webp | Bin 0 -> 14866 bytes .../editor/public/icons/skylight/walk-on.webp | Bin 0 -> 10950 bytes .../public/icons/solar-panel/compact.webp | Bin 0 -> 9660 bytes .../public/icons/solar-panel/frameless.webp | Bin 0 -> 9740 bytes .../icons/solar-panel/residential-large.webp | Bin 0 -> 9742 bytes .../public/icons/solar-panel/residential.webp | Bin 0 -> 10078 bytes .../public/icons/turbine-vent/cylinder.webp | Bin 0 -> 10964 bytes .../public/icons/turbine-vent/globe.webp | Bin 0 -> 13148 bytes .../core/src/contracts/reference-inventory.ts | 7 + .../registry/plugin-v1-conformance.test.ts | 13 +- packages/core/src/registry/types.ts | 2 + .../core/src/schema/host-children.test.ts | 14 +- packages/core/src/schema/nodes/box-vent.ts | 2 + .../core/src/schema/nodes/hvac-equipment.ts | 7 + packages/core/src/schema/nodes/lineset.ts | 25 +- .../core/src/services/distribution-frame.ts | 85 ++++++ packages/core/src/services/index.ts | 7 + .../core/src/services/port-connection.test.ts | 42 +++ packages/core/src/services/port-connection.ts | 142 ++++++++++ .../src/services/port-connectivity.test.ts | 153 +++++++++- .../core/src/services/port-connectivity.ts | 102 ++++--- .../core/src/services/system-graph.test.ts | 20 +- packages/core/src/services/system-graph.ts | 63 ++--- .../floorplan-registry-move-overlay.tsx | 89 ++++-- .../distribution/connection-compatibility.ts | 48 +--- .../registry/move-registry-node-tool.tsx | 66 +---- packages/editor/src/index.tsx | 1 + .../src/box-vent/__tests__/geometry.test.ts | 37 +++ .../src/box-vent/__tests__/schema.test.ts | 34 ++- packages/nodes/src/box-vent/definition.ts | 11 +- packages/nodes/src/box-vent/dimensions.ts | 34 +++ packages/nodes/src/box-vent/floorplan.ts | 13 + packages/nodes/src/box-vent/geometry.ts | 78 ++++-- packages/nodes/src/box-vent/index.ts | 3 + packages/nodes/src/box-vent/move-tool.tsx | 105 ++++--- packages/nodes/src/box-vent/panel.tsx | 146 +++++++--- packages/nodes/src/box-vent/parametrics.ts | 85 +++++- packages/nodes/src/box-vent/placement.test.ts | 69 +++++ packages/nodes/src/box-vent/placement.ts | 50 ++++ packages/nodes/src/box-vent/preview.tsx | 34 ++- packages/nodes/src/box-vent/renderer.tsx | 10 +- packages/nodes/src/box-vent/tool.test.tsx | 91 ++++++ packages/nodes/src/box-vent/tool.tsx | 241 ++++++++-------- .../src/chimney/__tests__/geometry.test.ts | 3 +- packages/nodes/src/chimney/definition.ts | 7 +- packages/nodes/src/chimney/geometry.ts | 32 ++- packages/nodes/src/chimney/index.ts | 1 + packages/nodes/src/chimney/move-tool.tsx | 55 ++-- packages/nodes/src/chimney/panel.tsx | 65 ++--- packages/nodes/src/chimney/placement.test.ts | 29 ++ packages/nodes/src/chimney/placement.ts | 45 +++ packages/nodes/src/chimney/preview.tsx | 39 +-- packages/nodes/src/chimney/renderer.tsx | 6 - packages/nodes/src/chimney/tool.tsx | 74 ++++- .../src/cupola/__tests__/geometry.test.ts | 18 +- packages/nodes/src/cupola/definition.ts | 6 +- packages/nodes/src/cupola/floorplan.ts | 27 +- packages/nodes/src/cupola/geometry.ts | 78 ++++-- packages/nodes/src/cupola/index.ts | 1 + packages/nodes/src/cupola/move-tool.tsx | 122 +++++--- packages/nodes/src/cupola/panel.tsx | 137 +++++---- packages/nodes/src/cupola/placement.test.ts | 64 +++++ packages/nodes/src/cupola/placement.ts | 97 +++++++ packages/nodes/src/cupola/preview.tsx | 39 ++- packages/nodes/src/cupola/renderer.tsx | 27 +- packages/nodes/src/cupola/tool.test.tsx | 90 ++++++ packages/nodes/src/cupola/tool.tsx | 220 ++++++++------- .../src/dormer/__tests__/geometry.test.ts | 27 +- packages/nodes/src/dormer/csg-geometry.ts | 13 +- packages/nodes/src/dormer/definition.ts | 11 +- packages/nodes/src/dormer/geometry.ts | 12 +- packages/nodes/src/dormer/index.ts | 2 + packages/nodes/src/dormer/move-tool.tsx | 52 ++-- .../src/dormer/panel-position-section.tsx | 45 ++- packages/nodes/src/dormer/panel.tsx | 88 +++--- packages/nodes/src/dormer/placement.test.ts | 40 +++ packages/nodes/src/dormer/placement.ts | 40 +++ packages/nodes/src/dormer/tool.test.tsx | 95 +++++++ packages/nodes/src/dormer/tool.tsx | 68 +++-- .../nodes/src/dormer/use-dormer-placement.ts | 168 ++++++++--- packages/nodes/src/downspout/geometry.ts | 21 +- packages/nodes/src/downspout/index.ts | 1 + .../nodes/src/downspout/inspector-editors.tsx | 95 ++++++- .../nodes/src/downspout/parametrics.test.ts | 29 +- packages/nodes/src/downspout/parametrics.ts | 37 ++- .../nodes/src/downspout/placement.test.ts | 33 +++ packages/nodes/src/downspout/placement.ts | 35 +++ packages/nodes/src/downspout/tool.test.tsx | 103 +++++++ packages/nodes/src/downspout/tool.tsx | 94 +++++-- packages/nodes/src/duct-segment/definition.ts | 6 + .../nodes/src/duct-terminal/definition.ts | 2 + packages/nodes/src/duct-terminal/index.ts | 1 + packages/nodes/src/duct-terminal/options.ts | 24 ++ .../nodes/src/duct-terminal/placement.test.ts | 123 ++++++++ packages/nodes/src/duct-terminal/placement.ts | 68 +++++ packages/nodes/src/duct-terminal/tool.tsx | 138 ++++----- .../eyebrow-vent/__tests__/geometry.test.ts | 51 +++- packages/nodes/src/eyebrow-vent/definition.ts | 4 +- packages/nodes/src/eyebrow-vent/floorplan.ts | 16 +- packages/nodes/src/eyebrow-vent/geometry.ts | 87 ++++-- packages/nodes/src/eyebrow-vent/index.ts | 1 + packages/nodes/src/eyebrow-vent/move-tool.tsx | 126 ++++++--- packages/nodes/src/eyebrow-vent/panel.tsx | 161 +++++++---- .../nodes/src/eyebrow-vent/placement.test.ts | 52 ++++ packages/nodes/src/eyebrow-vent/placement.ts | 47 ++++ packages/nodes/src/eyebrow-vent/preview.tsx | 18 +- packages/nodes/src/eyebrow-vent/renderer.tsx | 9 +- packages/nodes/src/eyebrow-vent/tool.test.tsx | 92 ++++++ packages/nodes/src/eyebrow-vent/tool.tsx | 230 +++++++-------- packages/nodes/src/gutter/definition.ts | 1 + .../nodes/src/gutter/downspouts-panel.tsx | 18 +- packages/nodes/src/gutter/eave-snap.ts | 31 +++ packages/nodes/src/gutter/floorplan.ts | 4 +- packages/nodes/src/gutter/geometry.ts | 14 +- packages/nodes/src/gutter/index.ts | 1 + packages/nodes/src/gutter/move-tool.tsx | 77 ++--- packages/nodes/src/gutter/panel.tsx | 133 +++++++++ packages/nodes/src/gutter/parametrics.ts | 7 +- packages/nodes/src/gutter/placement.test.ts | 35 +++ packages/nodes/src/gutter/tool.test.tsx | 91 ++++++ packages/nodes/src/gutter/tool.tsx | 51 +++- .../nodes/src/hvac-equipment/definition.ts | 14 +- packages/nodes/src/hvac-equipment/geometry.ts | 188 +++++-------- .../src/hvac-equipment/hvac-equipment.test.ts | 102 +++++++ packages/nodes/src/hvac-equipment/options.ts | 27 ++ .../nodes/src/hvac-equipment/parametrics.ts | 28 +- packages/nodes/src/hvac-equipment/ports.ts | 51 +++- packages/nodes/src/hvac-equipment/presets.ts | 10 + packages/nodes/src/hvac-equipment/tool.tsx | 12 +- packages/nodes/src/index.ts | 6 +- .../src/lean-to-extension/floorplan-tool.tsx | 40 ++- .../src/lean-to-extension/geometry.test.ts | 23 ++ .../nodes/src/lean-to-extension/geometry.ts | 54 ++-- .../nodes/src/lean-to-extension/layout.ts | 2 +- .../src/lean-to-extension/parametrics.ts | 45 ++- .../nodes/src/lean-to-extension/placement.ts | 1 + .../src/lean-to-extension/style-editor.tsx | 40 +++ packages/nodes/src/lean-to-extension/tool.tsx | 26 +- packages/nodes/src/lineset/definition.ts | 10 +- packages/nodes/src/lineset/parametrics.ts | 10 +- packages/nodes/src/lineset/tool.tsx | 5 +- packages/nodes/src/liquid-line/definition.ts | 2 + packages/nodes/src/liquid-line/tool.tsx | 5 +- .../src/ridge-vent/__tests__/geometry.test.ts | 21 +- packages/nodes/src/ridge-vent/definition.ts | 2 + packages/nodes/src/ridge-vent/floorplan.ts | 7 + packages/nodes/src/ridge-vent/geometry.ts | 31 ++- packages/nodes/src/ridge-vent/index.ts | 1 + packages/nodes/src/ridge-vent/move-tool.tsx | 94 ++++--- packages/nodes/src/ridge-vent/panel.tsx | 143 +++++++--- packages/nodes/src/ridge-vent/parametrics.ts | 6 +- .../nodes/src/ridge-vent/placement.test.ts | 53 ++++ packages/nodes/src/ridge-vent/placement.ts | 115 ++++++++ packages/nodes/src/ridge-vent/preview.tsx | 43 ++- packages/nodes/src/ridge-vent/renderer.tsx | 16 +- packages/nodes/src/ridge-vent/tool.test.tsx | 88 ++++++ packages/nodes/src/ridge-vent/tool.tsx | 235 +++++++--------- .../nodes/src/shared/connection-feedback.tsx | 21 +- .../src/shared/port-connectivity-pipe.test.ts | 2 +- packages/nodes/src/shared/ridge-snap.ts | 4 - .../nodes/src/shared/roof-footprint-trim.ts | 27 ++ packages/nodes/src/shared/system-checks.ts | 16 +- .../src/skylight/__tests__/geometry.test.ts | 36 ++- packages/nodes/src/skylight/definition.ts | 2 + packages/nodes/src/skylight/frame-csg.ts | 107 +++---- packages/nodes/src/skylight/geometry.ts | 22 ++ packages/nodes/src/skylight/index.ts | 4 +- packages/nodes/src/skylight/move-tool.tsx | 220 +++++++-------- packages/nodes/src/skylight/panel.tsx | 81 +++--- packages/nodes/src/skylight/placement.test.ts | 41 +++ packages/nodes/src/skylight/placement.ts | 49 ++++ packages/nodes/src/skylight/preview.tsx | 42 ++- packages/nodes/src/skylight/renderer.tsx | 6 +- packages/nodes/src/skylight/tool.tsx | 233 ++++++++-------- .../solar-panel/__tests__/geometry.test.ts | 204 +++++++++++++- packages/nodes/src/solar-panel/auto-fit.ts | 136 +++++++++ packages/nodes/src/solar-panel/definition.ts | 5 + packages/nodes/src/solar-panel/geometry.ts | 88 +++--- packages/nodes/src/solar-panel/index.ts | 5 +- packages/nodes/src/solar-panel/move-tool.tsx | 248 ++++++++--------- packages/nodes/src/solar-panel/panel.tsx | 65 +++-- packages/nodes/src/solar-panel/placement.ts | 37 +++ packages/nodes/src/solar-panel/preview.tsx | 20 +- packages/nodes/src/solar-panel/renderer.tsx | 2 + packages/nodes/src/solar-panel/tool.test.tsx | 78 ++++++ packages/nodes/src/solar-panel/tool.tsx | 263 +++++++++--------- .../turbine-vent/__tests__/geometry.test.ts | 13 + packages/nodes/src/turbine-vent/definition.ts | 2 + packages/nodes/src/turbine-vent/floorplan.ts | 2 +- packages/nodes/src/turbine-vent/geometry.ts | 53 ++-- packages/nodes/src/turbine-vent/index.ts | 1 + packages/nodes/src/turbine-vent/move-tool.tsx | 126 ++++++--- packages/nodes/src/turbine-vent/panel.tsx | 144 ++++++---- .../nodes/src/turbine-vent/placement.test.ts | 54 ++++ packages/nodes/src/turbine-vent/placement.ts | 44 +++ packages/nodes/src/turbine-vent/preview.tsx | 18 +- packages/nodes/src/turbine-vent/renderer.tsx | 11 +- packages/nodes/src/turbine-vent/tool.test.tsx | 93 +++++++ packages/nodes/src/turbine-vent/tool.tsx | 230 +++++++-------- wiki/design/box-vent-thumbnails.md | 16 ++ wiki/design/canopy-thumbnails.md | 21 ++ wiki/design/chimney-thumbnails.md | 27 ++ wiki/design/cupola-thumbnails.md | 12 + wiki/design/dormer-thumbnails.md | 75 +++++ wiki/design/downspout-thumbnails.md | 35 +++ wiki/design/eyebrow-vent-thumbnails.md | 27 ++ wiki/design/gutter-thumbnails.md | 21 ++ wiki/design/ridge-vent-thumbnails.md | 16 ++ wiki/design/skylight-thumbnails.md | 53 ++++ wiki/design/solar-panel-thumbnails.md | 35 +++ wiki/design/turbine-vent-thumbnails.md | 12 + 250 files changed, 8060 insertions(+), 2920 deletions(-) create mode 100644 apps/editor/lib/build-search.test.ts create mode 100644 apps/editor/lib/build-search.ts create mode 100644 apps/editor/public/icons/box-vent/box.webp create mode 100644 apps/editor/public/icons/box-vent/cap.webp create mode 100644 apps/editor/public/icons/box-vent/dome.webp create mode 100644 apps/editor/public/icons/canopy/butterfly.webp create mode 100644 apps/editor/public/icons/canopy/gable.webp create mode 100644 apps/editor/public/icons/canopy/mono.webp create mode 100644 apps/editor/public/icons/chimney/brick.webp create mode 100644 apps/editor/public/icons/chimney/modern.webp create mode 100644 apps/editor/public/icons/chimney/round.webp create mode 100644 apps/editor/public/icons/cupola/dome.webp create mode 100644 apps/editor/public/icons/cupola/pyramid.webp create mode 100644 apps/editor/public/icons/dormer/dutch.webp create mode 100644 apps/editor/public/icons/dormer/flat.webp create mode 100644 apps/editor/public/icons/dormer/gable.webp create mode 100644 apps/editor/public/icons/dormer/gambrel.webp create mode 100644 apps/editor/public/icons/dormer/hip.webp create mode 100644 apps/editor/public/icons/dormer/mansard.webp create mode 100644 apps/editor/public/icons/dormer/shed.webp create mode 100644 apps/editor/public/icons/downspout/kickout.webp create mode 100644 apps/editor/public/icons/downspout/rect.webp create mode 100644 apps/editor/public/icons/downspout/round.webp create mode 100644 apps/editor/public/icons/downspout/splash.webp create mode 100644 apps/editor/public/icons/downspout/straight.webp create mode 100644 apps/editor/public/icons/eyebrow-vent/half-round.webp create mode 100644 apps/editor/public/icons/eyebrow-vent/scoop.webp create mode 100644 apps/editor/public/icons/eyebrow-vent/slant-box.webp create mode 100644 apps/editor/public/icons/gutter/box.webp create mode 100644 apps/editor/public/icons/gutter/half-round.webp create mode 100644 apps/editor/public/icons/gutter/k-style.webp create mode 100644 apps/editor/public/icons/ridge-vent/metal.webp create mode 100644 apps/editor/public/icons/ridge-vent/shingled.webp create mode 100644 apps/editor/public/icons/ridge-vent/standard.webp create mode 100644 apps/editor/public/icons/skylight/flat.webp create mode 100644 apps/editor/public/icons/skylight/lantern.webp create mode 100644 apps/editor/public/icons/skylight/opening.webp create mode 100644 apps/editor/public/icons/skylight/sliding.webp create mode 100644 apps/editor/public/icons/skylight/walk-on.webp create mode 100644 apps/editor/public/icons/solar-panel/compact.webp create mode 100644 apps/editor/public/icons/solar-panel/frameless.webp create mode 100644 apps/editor/public/icons/solar-panel/residential-large.webp create mode 100644 apps/editor/public/icons/solar-panel/residential.webp create mode 100644 apps/editor/public/icons/turbine-vent/cylinder.webp create mode 100644 apps/editor/public/icons/turbine-vent/globe.webp create mode 100644 packages/core/src/services/distribution-frame.ts create mode 100644 packages/core/src/services/port-connection.test.ts create mode 100644 packages/core/src/services/port-connection.ts create mode 100644 packages/nodes/src/box-vent/dimensions.ts create mode 100644 packages/nodes/src/box-vent/placement.test.ts create mode 100644 packages/nodes/src/box-vent/placement.ts create mode 100644 packages/nodes/src/box-vent/tool.test.tsx create mode 100644 packages/nodes/src/chimney/placement.test.ts create mode 100644 packages/nodes/src/chimney/placement.ts create mode 100644 packages/nodes/src/cupola/placement.test.ts create mode 100644 packages/nodes/src/cupola/placement.ts create mode 100644 packages/nodes/src/cupola/tool.test.tsx create mode 100644 packages/nodes/src/dormer/placement.test.ts create mode 100644 packages/nodes/src/dormer/placement.ts create mode 100644 packages/nodes/src/dormer/tool.test.tsx create mode 100644 packages/nodes/src/downspout/placement.test.ts create mode 100644 packages/nodes/src/downspout/placement.ts create mode 100644 packages/nodes/src/downspout/tool.test.tsx create mode 100644 packages/nodes/src/duct-terminal/options.ts create mode 100644 packages/nodes/src/duct-terminal/placement.test.ts create mode 100644 packages/nodes/src/duct-terminal/placement.ts create mode 100644 packages/nodes/src/eyebrow-vent/placement.test.ts create mode 100644 packages/nodes/src/eyebrow-vent/placement.ts create mode 100644 packages/nodes/src/eyebrow-vent/tool.test.tsx create mode 100644 packages/nodes/src/gutter/panel.tsx create mode 100644 packages/nodes/src/gutter/placement.test.ts create mode 100644 packages/nodes/src/gutter/tool.test.tsx create mode 100644 packages/nodes/src/hvac-equipment/hvac-equipment.test.ts create mode 100644 packages/nodes/src/hvac-equipment/options.ts create mode 100644 packages/nodes/src/hvac-equipment/presets.ts create mode 100644 packages/nodes/src/lean-to-extension/style-editor.tsx create mode 100644 packages/nodes/src/ridge-vent/placement.test.ts create mode 100644 packages/nodes/src/ridge-vent/placement.ts create mode 100644 packages/nodes/src/ridge-vent/tool.test.tsx create mode 100644 packages/nodes/src/shared/roof-footprint-trim.ts create mode 100644 packages/nodes/src/skylight/placement.test.ts create mode 100644 packages/nodes/src/skylight/placement.ts create mode 100644 packages/nodes/src/solar-panel/auto-fit.ts create mode 100644 packages/nodes/src/solar-panel/placement.ts create mode 100644 packages/nodes/src/solar-panel/tool.test.tsx create mode 100644 packages/nodes/src/turbine-vent/placement.test.ts create mode 100644 packages/nodes/src/turbine-vent/placement.ts create mode 100644 packages/nodes/src/turbine-vent/tool.test.tsx create mode 100644 wiki/design/box-vent-thumbnails.md create mode 100644 wiki/design/canopy-thumbnails.md create mode 100644 wiki/design/chimney-thumbnails.md create mode 100644 wiki/design/cupola-thumbnails.md create mode 100644 wiki/design/dormer-thumbnails.md create mode 100644 wiki/design/downspout-thumbnails.md create mode 100644 wiki/design/eyebrow-vent-thumbnails.md create mode 100644 wiki/design/gutter-thumbnails.md create mode 100644 wiki/design/ridge-vent-thumbnails.md create mode 100644 wiki/design/skylight-thumbnails.md create mode 100644 wiki/design/solar-panel-thumbnails.md create mode 100644 wiki/design/turbine-vent-thumbnails.md diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx index efd3092f49..70bc026a9b 100644 --- a/apps/editor/components/build-tab.tsx +++ b/apps/editor/components/build-tab.tsx @@ -22,6 +22,7 @@ import { useFloorplanMode, useTerraceDraft, useWallDrawVariant, + WALL_DRAW_VARIANTS, } from '@pascal-app/editor' import { useLiquidLineToolOptions } from '@pascal-app/nodes' import { useViewer } from '@pascal-app/viewer' @@ -42,11 +43,13 @@ import { BASE_BUILD_TYPES, type BuildType, collectBuildTypes, + collectMepSearchTools, collectRoofFeatures, MEP_ITEMS, MEP_TOOL_KINDS, type MepItem, } from '@/lib/build-palette' +import { searchBuildTools } from '@/lib/build-search' import { getActiveRoofFeatureId, ROOF_TYPE_OPTIONS } from '@/lib/build-tab-state' import { cn } from '@/lib/utils' import { KitchenBrowser } from './kitchen-browser' @@ -194,6 +197,54 @@ export function BuildTab() { buildTypes.filter((type) => type.section === section) const advancedTypes = typesIn('advanced') + const searchResults = searchBuildTools( + [ + ...buildTypes + .filter((type) => type.id !== 'wall') + .map((type) => ({ + ...type, + active: isTypeActive(type), + activate: () => handleTypeClick(type), + })), + ...WALL_DRAW_VARIANTS.map((variant) => ({ + id: variant.id === 'walls' ? 'wall' : `room-${variant.id}`, + label: variant.title, + iconSrc: variant.iconSrc, + active: mode === 'build' && activeTool === 'wall' && wallVariant === variant.id, + activate: () => { + selectWallDrawVariant(variant.id) + const editor = useEditor.getState() + if (!(editor.mode === 'build' && editor.tool === 'wall')) activateBuildTool('wall') + }, + })), + ...(registryReady ? collectMepSearchTools() : MEP_ITEMS).map((item) => ({ + ...item, + active: isMepItemActive(item), + activate: () => activateBuildTool(item.kind), + })), + ...ROOF_TYPE_OPTIONS.map((roofType) => ({ + id: `roof-${roofType.value}`, + label: `${roofType.label} roof`, + iconSrc: '/icons/roof.webp', + active: mode === 'build' && activeTool === 'roof' && activeRoofType === roofType.value, + activate: () => activateRoofType(roofType.value), + })), + ...(['gate', 'opening'] as const).map((kind) => ({ + id: `fence-${kind}`, + label: kind === 'gate' ? 'Fence gate' : 'Fence opening', + iconSrc: kind === 'gate' ? '/icons/gate.webp' : '/icons/open-passage.webp', + active: placingFenceFeature === kind, + activate: () => activateFenceFeaturePlacement(kind), + })), + ...roofFeatures.map((feature) => ({ + ...feature, + active: mode === 'build' && feature.id === activeRoofFeatureId, + activate: () => activateRoofFeatureTool(feature), + })), + ], + buildSearch, + ) + return (
+ {(activeTool === 'hvac-equipment' || activeTool === 'duct-terminal') && ( + + )} + {(['duct-fitting', 'pipe-fitting'] as const) .filter((kind) => (kind === 'duct-fitting' ? ductContext : pipeContext)) .map((kind) => ( diff --git a/apps/editor/lib/build-palette.ts b/apps/editor/lib/build-palette.ts index 5b8bd1f385..c043d0b8e4 100644 --- a/apps/editor/lib/build-palette.ts +++ b/apps/editor/lib/build-palette.ts @@ -98,7 +98,7 @@ export const MEP_ITEMS: MepItem[] = [ kind: 'duct-terminal', }, { id: 'hvac-equipment', label: 'HVAC Unit', iconSrc: '/icons/HVAC.webp', kind: 'hvac-equipment' }, - { id: 'lineset', label: 'Lineset', iconSrc: '/icons/lineset.webp', kind: 'lineset' }, + { id: 'lineset', label: 'Suction line', iconSrc: '/icons/lineset.webp', kind: 'lineset' }, { id: 'liquid-line', label: 'Liquid Line', iconSrc: '/icons/lineset.webp', kind: 'liquid-line' }, { id: 'pipe-segment', label: 'DWV Pipe', iconSrc: '/icons/dwv-pipes.webp', kind: 'pipe-segment' }, ] @@ -110,6 +110,23 @@ export const MEP_TOOL_KINDS = new Set([ 'pipe-trap', ]) +export function collectMepSearchTools(): MepItem[] { + const tools = [...MEP_ITEMS] + for (const kind of MEP_TOOL_KINDS) { + if (tools.some((tool) => tool.kind === kind)) continue + const definition = nodeRegistry.get(kind) + if (!definition?.tool || !definition.presentation) continue + const { label, icon } = definition.presentation + tools.push({ + id: kind, + kind, + label, + iconSrc: icon.kind === 'url' ? icon.src : '/icons/HVAC.webp', + }) + } + return tools +} + const MODULAR_CABINET_CATALOG_ITEM = CATALOG_ITEMS.find((item) => item.id === 'cabinet') export const MODULAR_CABINET_ICON = MODULAR_CABINET_CATALOG_ITEM?.thumbnail ?? '/icons/item.webp' diff --git a/apps/editor/lib/build-search.test.ts b/apps/editor/lib/build-search.test.ts new file mode 100644 index 0000000000..1b71190859 --- /dev/null +++ b/apps/editor/lib/build-search.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from 'bun:test' +import { searchBuildTools } from './build-search' + +const tools = [ + { id: 'slab', label: 'Slab' }, + { id: 'hvac-equipment', label: 'HVAC Unit' }, + { id: 'room-rectangle', label: 'Rectangle room' }, + { id: 'room-polygon', label: 'Polygon room' }, + { id: 'skylight', label: 'Skylight' }, + { id: 'duct-segment', label: 'Duct' }, + { id: 'terrace', label: 'Terrace' }, +] + +describe('build tool search', () => { + test('finds room shapes, roof accessories and MEP tools', () => { + for (const [query, id] of [ + ['rectangle', 'room-rectangle'], + ['skylight', 'skylight'], + ['light', 'skylight'], + ['duct', 'duct-segment'], + ]) { + expect(searchBuildTools(tools, query!).map((tool) => tool.id)).toEqual([id!]) + } + }) + + test('matches aliases, whitespace, casing and multiple words', () => { + expect(searchBuildTools(tools, ' FLOOR ')).toEqual([tools[0]!]) + expect(searchBuildTools(tools, 'AC')).toEqual([tools[1]!]) + expect(searchBuildTools(tools, 'air conditioner')).toEqual([tools[1]!]) + expect(searchBuildTools(tools, 'room rect')).toEqual([tools[2]!]) + expect(searchBuildTools(tools, 'L-shaped room')).toEqual([tools[3]!]) + expect(searchBuildTools(tools, 'unknown tool')).toEqual([]) + }) + + test('deduplicates shared tools and preserves their activation action', () => { + let selected = '' + const canopy = { + id: 'canopy', + label: 'Canopy', + activate: () => { + selected = 'canopy' + }, + } + const results = searchBuildTools([canopy, canopy], 'canopy') + expect(results).toHaveLength(1) + results[0]!.activate() + expect(selected).toBe('canopy') + }) +}) diff --git a/apps/editor/lib/build-search.ts b/apps/editor/lib/build-search.ts new file mode 100644 index 0000000000..3560ce8b2c --- /dev/null +++ b/apps/editor/lib/build-search.ts @@ -0,0 +1,36 @@ +type SearchableBuildTool = { + id: string + label: string +} + +const TOOL_ALIASES: Record = { + slab: ['floor', 'floor plate', 'platform'], + 'hvac-equipment': ['ac', 'air conditioning', 'air conditioner', 'heat pump'], + 'duct-terminal': ['air vent', 'register', 'diffuser'], + 'pipe-segment': ['plumbing', 'drain', 'waste', 'vent pipe'], + lineset: ['refrigerant line'], + 'room-rectangle': ['rectangular room'], + 'room-polygon': ['polygonal room', 'l shaped room'], +} + +function normalize(value: string): string { + return value.toLowerCase().replace(/[-_]/g, ' ').trim() +} + +export function searchBuildTools( + tools: readonly T[], + query: string, +): T[] { + const terms = normalize(query).split(/\s+/).filter(Boolean) + const seen = new Set() + return tools.filter((tool) => { + if (seen.has(tool.id)) return false + seen.add(tool.id) + const words = normalize( + [tool.label, tool.id, ...(TOOL_ALIASES[tool.id] ?? [])].join(' '), + ).split(/\s+/) + return terms.every((term) => + words.some((word) => (term.length <= 2 ? word.startsWith(term) : word.includes(term))), + ) + }) +} diff --git a/apps/editor/public/icons/box-vent/box.webp b/apps/editor/public/icons/box-vent/box.webp new file mode 100644 index 0000000000000000000000000000000000000000..e45b944114f51e32273af6fb4feaf579cf533b54 GIT binary patch literal 9738 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 Y=Kw}aL-&6_>_gM_zDTIx000000N*MX+5i9m literal 0 HcmV?d00001 diff --git a/apps/editor/public/icons/box-vent/cap.webp b/apps/editor/public/icons/box-vent/cap.webp new file mode 100644 index 0000000000000000000000000000000000000000..4bda11cc30fd60f8d610e35a277a87ecd5439c39 GIT binary patch literal 10048 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 WDWJb1OwWG|P?7)u000000000-S);}P literal 0 HcmV?d00001 diff --git a/apps/editor/public/icons/box-vent/dome.webp b/apps/editor/public/icons/box-vent/dome.webp new file mode 100644 index 0000000000000000000000000000000000000000..624692d20eb40b06f7fea200eb572785080ddc47 GIT binary patch literal 9062 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 literal 0 HcmV?d00001 diff --git a/apps/editor/public/icons/canopy/butterfly.webp b/apps/editor/public/icons/canopy/butterfly.webp new file mode 100644 index 0000000000000000000000000000000000000000..761ff877ad77bf3d1383297bc1769ed3814e7a4b GIT binary patch literal 10200 zcmV;}CnwlaNk&G{CjbCfMM6+kP&il$0000G000300093006|PpNInk$00E!{0FWU& znwp2)bL`~I;B4ErZQEA(V%xUQh;3VkSPwhh^Rch1ySjR3=kG2eAlkMSBsx49HMGkG z39b17FZR>_{`23Zz<(*61!BocV3n8zRI%{_Z^Y70=FoS-IDQxK^Z>+*&j9>V)BYcD z^G*;iZv?3^OhbAIP9UxIL2HisbZJ7A{rqG# zcHi_1Ksu*knUEFfpKp)%xxI@C;JAc}3=WOHDvKZwSV`0Am;fQAD4+R46_b5Tc0zim zK_K<%uNEg|u%n%b!~rrmGV(&7{i04QyZ9>)jdX+|I`xmYX1VY%E$;=W+H9}Xp-~`K zzf%*qn>szO<-W8{N&~u;-QU#nQ~!8~I9d0u2!!!9{mT9xr-Bz_DeJ!334Mu*WL`Nu z;)d+F!oTq6WO)O$6&cLBC6-p8>7bJ2!V@xB_-sCLuubPBPVaLlNG|4}uZUHz)r2_t zuxZ$@RFV@VR2gE|ceNodW*oJ?-K~_j+d&zPMX;y+2@nSqy#7z}JiOpiA~@9fl(hdb zT=3!FLI^v|ub5PnjXBp(=btr|9$HN+Z{ss*1TLL$O-97bN+B}un4gfYQ&@58@?Wpd zbUW%pWT)7(11X$%P5K;8EO@k%D{}ApWa#L#A%bLF@n_(ytA&&ZL$U0c0yo?i2q6x4 ztRA^HId5W_adhE^cIx#n<-6b>Wy}{_Ud@kE0sq_~CBl4*!5E>%a4gopTg3%v_+D{E zW<&-cfTkl#1$lzaeK@H*ZvUvZ15heiJi67h*Pf7{k>aO=(j8R#&y#~BpuFRl-4ToV{Ovauf~O8;IoruFN0o?SEB zcfA5Nzdb;mW)R^#l-6(d79YdqQO2EXw1kAWL1zjQM|Lb4-{IAp�LNjwY*BKKv`N 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 literal 0 HcmV?d00001 diff --git a/apps/editor/public/icons/canopy/mono.webp b/apps/editor/public/icons/canopy/mono.webp new file mode 100644 index 0000000000000000000000000000000000000000..db0cc44bf051d6f67f150d97e762960bfc74d84d GIT binary patch literal 9698 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 a11{_TU7!j}3IL4D!*5VQgjJvb0000we7Xk! literal 0 HcmV?d00001 diff --git a/apps/editor/public/icons/cupola/pyramid.webp b/apps/editor/public/icons/cupola/pyramid.webp new file mode 100644 index 0000000000000000000000000000000000000000..683b1b59c0680c993762c66ec6924e84765f53cb GIT binary patch literal 12356 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 literal 0 HcmV?d00001 diff --git a/apps/editor/public/icons/dormer/dutch.webp b/apps/editor/public/icons/dormer/dutch.webp new file mode 100644 index 0000000000000000000000000000000000000000..692b5b74603e6fbcf48ccd668953498c9637f384 GIT binary patch literal 11140 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 WjFMGi^1h2YR}^h0f(t+Z0000i;Gu8; literal 0 HcmV?d00001 diff --git a/apps/editor/public/icons/dormer/flat.webp b/apps/editor/public/icons/dormer/flat.webp new file mode 100644 index 0000000000000000000000000000000000000000..6aebdbbe0938744e0efa7fbe983ffd8804f2244e GIT binary patch literal 9512 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 G0000LjinF( literal 0 HcmV?d00001 diff --git a/apps/editor/public/icons/dormer/gable.webp b/apps/editor/public/icons/dormer/gable.webp new file mode 100644 index 0000000000000000000000000000000000000000..492196b688b09ecd16c2655cd99ed0e124b314ee GIT binary patch literal 10488 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 mH#Z)V(Pdd*9RY<|#U&LZ*h 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 literal 0 HcmV?d00001 diff --git a/apps/editor/public/icons/dormer/gambrel.webp b/apps/editor/public/icons/dormer/gambrel.webp new file mode 100644 index 0000000000000000000000000000000000000000..2eb7adc096992906f2dc620fc27e14e0d2607ca4 GIT binary patch literal 12620 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 literal 0 HcmV?d00001 diff --git a/apps/editor/public/icons/dormer/hip.webp b/apps/editor/public/icons/dormer/hip.webp new file mode 100644 index 0000000000000000000000000000000000000000..4f49ef8320608ec57ba4246a3f168375cb6e6e5d GIT binary patch literal 10406 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 Q>=Uu6#FGF30000006r0CkN^Mx literal 0 HcmV?d00001 diff --git a/apps/editor/public/icons/dormer/mansard.webp b/apps/editor/public/icons/dormer/mansard.webp new file mode 100644 index 0000000000000000000000000000000000000000..88cd0c323df48ad1d95b9918f27768bf2edb2d4d GIT binary patch literal 8626 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 E09hvP>Hq)$ literal 0 HcmV?d00001 diff --git a/apps/editor/public/icons/dormer/shed.webp b/apps/editor/public/icons/dormer/shed.webp new file mode 100644 index 0000000000000000000000000000000000000000..248ef70e892a9873dc02347be8b31268ed91b29d GIT binary patch literal 7752 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 OR^D#_000000002IlTT;> literal 0 HcmV?d00001 diff --git a/apps/editor/public/icons/downspout/kickout.webp b/apps/editor/public/icons/downspout/kickout.webp new file mode 100644 index 0000000000000000000000000000000000000000..5ae967773607f8f863041fd16d5a95586aa28553 GIT binary patch literal 4826 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 literal 0 HcmV?d00001 diff --git a/apps/editor/public/icons/downspout/straight.webp b/apps/editor/public/icons/downspout/straight.webp new file mode 100644 index 0000000000000000000000000000000000000000..e418533dca7f44574898dd7ef896e17c36634a3d GIT binary patch literal 5308 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 O1}T#Pll~vD0001-Dp6Aa literal 0 HcmV?d00001 diff --git a/apps/editor/public/icons/eyebrow-vent/half-round.webp b/apps/editor/public/icons/eyebrow-vent/half-round.webp new file mode 100644 index 0000000000000000000000000000000000000000..b66905f9a6cfa2fc47a163b8ad448072a3132531 GIT binary patch literal 8478 zcmV+(A>rOqNk&E%ApihZMM6+kP&il$0000G000300093006|PpNQ(>r00HnoZF^a5 z+qUg{JCF0cY}>YN+qP}nwr$(CZQH(1V?eT+73(7VSb3@x89Ns_b>k_3Cg3P6u-v!QlS zGVcZ3-dXbVNhF=rR&4C4=2~)kU_5X5MMXL7TPXs|wrhpsZ7pD>(Wr8y)#HNTnse$``7L`d7FjC%okuQi$y=n0`rn&wmok9bU0hVkdj$YinOH$t1s`^XW`MT zf;KLytyh|4C37G5#&9-lq{v5`bTLpq16@JPOdp@O`nZ;!?JBdhs0RNeyFAxr))_Bp z$B?lKp_%#ntCs6z>`AN2MW_>#6d+yh+CSKk$m(hbC8S~f_5oAn3ikxtsFMIpyvxhP zM#}0E_iltT{MY%j*I>XB1zOMQ!~6`H{es_^mB>qcfim{4<`K8glPlhK{uMf-c!G|; z?gPWwhyuUQ0#Y&~u9~4)jnMB-16UhrE_=UEn6*$^`%f7=3B?1C9>?<80;>n*%@;mc3kE?c>J2m!}&yju7ga=WX$Sx1s1lr9XEqF?k9r7hJ1>A0& zQb83#e0+06U?2(s<~r{WHr6>CABdFT>-!*SD=l#t__@}5)uB~K^tSabH3QLdj)hPJ&iJB9gZ7Zxe zY8Jm~5bLg_K zf`{m$YSl|?!FR_Ctj{sW6@yp{Md7Gg(5|$T_JR*;qJZ4Un)~XZOhU!M;nZh7nO>0b zm|+V*(m*-0T-(hlq7d?|Tn)I&t2KL)pYH(1w!_w!Gw-d#nM7fryrX__(^S`jmV6?l z5x^@o+oi+GE|g+Oy8eT7O%RDiBPc?T)J&HRXVq1t3y6S27e>3l=ig<^1wIbg2uBNZ z$NIt{tSk2$BA-m9;!3AM2M!s}ju8q(H_v*KV}5tJO7%NzuJAHnwekIAfDJfmN8j~l zrrfa(2y2YX3j9T?4HA4lq_H~~kZk%f>kubxm6{cZD3d*9)+F-M1s*HjB*zCG1GMDZ zA&I(l;tRSgWRUTpBFfH|JXe7jLgcR$VdEcIi9l32sEl97hWzy)U@YgYL+RwVvlc2; zX&F@!OXk5=Ar6vP2;T&4RLk7rr%|Q)YyHJtkL!Y{cC1#AC@K*DJvCrJ zpj+BrYZ*b(+5tLBXu*dEWk#p8qahXGlp(6~a?~fL?1!TPMuZf$HA-o}a34!@DJoSm zs_M04@;*{nRg!l*+Z#gLTjY$w9dtAuB{1u6thA0LdVtqis54`V2TTh|BtRJN4VF3{ z=n}X)9DY#L)Ey*vw`j5{+{{7;h9kQ!k>@>I@k8q13BXv=1}uXcTh=&NubMJ`qA(L$ zR}9<8xxpua4wayw!UGEX$NSt5F}yqyJl}2L>AgNyEUX? zANQB!TN;8-3<`vaerBroZN>T(62*j@gCc=tYTMjFK~#f^2qgIxz@Rk;u!s)E2LeXp zgfGVm856kGAB-2Ra#rKd7mMYgc)&D}1gz=%fMrOugD7Pd4u}M%8^mpU(l}+;fHYvu zvaFgtXcPkkZxa-|uxxv1!3RPo7&>rLu$Cs47zs`Yh!iewI&>VkAYdkeyQ(wfZ8fyY z4DMf*Owp0mC7A&jT7?NdJWJa8Sqd zRhF%vk^E>tBycB-miIe|GM0cyVILmZ`hHiJZwu;oPw-`cXpAv!0yUBkg%kp27yE^^ zRQ_YwotTKC^13zOJ%;xTR>Y|e5LNf4@VD-fb5gxFDUP+Y`i0(B<7H7*$Ank zs*VyO>*Ul4AaOZvG2};Rui?JsZydlTKZaT?D8cIbnVmbq7T%v#8{=tOFMc}2i9fo25z2adVHL&Mof8T=Ru z>TpnTyH)(dt{upz1_k?QoAuOha(0Ra0bPs{XN+IH-C`5iI|;y88$a5Oh+R{F*~&Yi zyXOY1vNRCm0oG;D_~t(4TQ*v#@UJksDxvw6Wx?_|q;=Oh~`gJ*+SnZ)1k z*r{V_G=l-s4gfB47QTo1FobDD7qm6~%#pz&@{}cM`1xgAU5tEe+|e+T8~_)* zX0MLtQ1ep1_FPJG3&s1oBb_sVot4bO+fJF2pIt3VO$5)7rR57&r zb{o4BFF=eImA}Umd$RP61KB7UmQps!ojS5!o`IyqvTvYVCC?+dF3!Y*k5!u4bgUf&uITlbWe z?a4JN$?CQW7X!iV%ZjuXJ;LAbwfBy>&i_0U(yvomWwelBx%)w;`mv$Q}nwWtZj9i&7V}PyWPA% zb|us86`dg3KzmYqPOwLK$?FHO;XKFu)G|=rs*qZp@~{U_vC~~&4IDn;txH#Px?3pZ zTQ_+Z4P#6_>y&A}5WRioy+(A$9_~)+s}6foPnaOK;^@G?>Y=4}L+tsp+&g!HeE(05DttodGHU0RRC$ zF&K(Nq9LIZn_Orh0|c_SaJcY%_MhxX=DMGK{HONYxqqbmUCKabyOhp1`@c~ip8wbX zT=)R}BmYzD&;AFk59pupy+wZ{{>l3{??vd-{Ew(F_>b@%Vt=<@z#pzYnxD8I1OK=m zx8DH2$-lCH-2DIU0Q3OtlK#v1dHSE`C;6|*kJ)cwe;fMG_8A~}$>9ITKk@$3eg?ml zf1>`g>l5_f==^J|zo1{UA4UI({e#%|>HPBkhsgi_SCpUoe<6Pq_67by{SW=m_OHpG zlMna*(s}y&FaG!WkAOeqKheM1e|Y~f?-T6@_CwhF^#kv?iH1S~f<6U@6Y@wU9F+uo z3l1mbkV-i!2>2EpPrJ+{5{^oL8oAiSZ$YnD|No-3LiS$fRmcYJ9lEH8>r(x(!>oZd zyfaR6cU&0m=+}IODhN>-8z^VcBsU)EeJ8Mnd_Y{r7T~p_0<>a zCW?WeL=rB6v!cnECrDT6v?Y$A zX8!A^ZTM#&=Q-kw|2`iq$KpW?$=zSK68+v46V?CNUIU! zMm;oYlNZAbf2&NwZNf49|FUOb#ZYXW8heEKO}-t$xuhY zu;PA61eX8+{{P4h000008Q);H|AWu}3@88Sg1`TlCr{7+$+T|mut)H_>Q8yU&t_@? zll7M}Y>K!kFd*KeZD!^A+_gON8-rp#3~SmT1IF3+C6LY?M%;zWiMz519bDD96IA%cQ0FY8ZCL!odLf&o^roEO(njZo6iYg ze9Xku*`)-(+aOvK=PO*H;K#v}t+tj7Rxr@Gz8@G8Vd`+m44*P@r3};94P~TmdUPbj z9gG6%Q>vnI!Fb)>QU7>GL;eI6K_BMlz1DQu!~O!&6Han-m7ymdjK|P=#&YPj*tAC5 z5T(Hy;LJmZNL}ahq;I%crxu@ig(Z&omRq^Ig7bbWi^}P*IL*Kdu+Q9B{Y7^=kgR3i zo+|)QZrMPfVi0Ei6psp0jewiKLC*g}-M_QaON%3~2P~S*GIMrrT*G3%5WGiwPphmU zi|h9Tp*#{NY0`(0cmJuB&wEUl*w?Qny631|OL~93<^`Voln^xfvt=i*{}=isSNtQ( z$*f0f*@Z}KZBVcBfJJbUbG2EF+8ZwWuKB}BCA|^S9^LKAAETxLi%mAkO*Ha6#FLg3U@qy15*ne>Ru*4=b2q@uuXy6T~JzUnjsBB!s?xGnJ9y{Uq`xUVxcMhj>z`$73S=E5KA z2^LAbXr#Y*7&_@35JF$e%LZMMb0? z1CK@Iz1JwQFPBF)I{eDMOiI734ckQM!UJ31{Iq}&hk_8q1|t^~${T~TQDrQ~g- zxo%f$aY?Ur^T>a`x_2Pycl6bW>s+4EIvWp4T+VhSX%V|F(6L-R1u*VzTErq`l9vN} zS7}6)|MAPb`-*^@xIhrZLJZe{7wGT0Gm|&lh&3eX8I(JBxietUc^|7&DP9@#^Fze7 zPM%Hrb8_7c?Nk4=^e^#`LBP!ayVtw$|8KVGL&gZ2IO_?_ck*-Hy0_L*NMytE=h#OhT@K|FE`tA5=1nQNHO`!1v>q6_XyPl)o1h5Lgtgi&qOlXu7(7J zrfKMj>?PELu-4(DF(iNrcuga|1b8ILE2P7mC<2g^R|0zXyPVtb)8sEd+YO2%%y5-4 zvHH^L78*6z&iC~ZLNh>%!pdb39iLwm>&p<7HdTQQH`G*s^zYP^_K8>wkTSf^?#@31 z(6wH?000wTSzh|20(k6~%^&V*d|HZ z@)7ABm|kX!vgGUO1W7b6pSz6fN}@%PBV zAnlTeCEH^xaQ}{~mM^Q`YTc0ZJ{ z_$D(bXg8!S;}QFC7C8Z+up?du-@F0psh3@xtAn+1(XvZklpZ~fw}_m8;UP(u7VLr) zJ}pg;v_Mq4fX@O_aKM54EIWh;RCx`I0?P-z)l~QxdYPAq- zPKJn9d*jD>hAnGn3nVC0#~0yR~)>TjDGJh z_To3;^YqCpD-gEAlT^WSNj3RVZkkl>Mml{&Qfol*NafV1EUkt)Oj_yu{Mv9UoY#0% z9J4?lc$k8h`9^W}`_V0yzy0Q>Ch!0OT?8%56DLuch_t-!8;wYA} znYJnUTYwN+yzYQqpG=qz=~hQd!wrrwMlx-rM>@ko3J6$VD<>z|02|CbUm0CyuIPAh zo${sxF*Q->zj-TyIt+Yam`$a4wrxsJ6s3&Hd~K#`EcP~ntXm^5iVYRni-FM+_Ya*P zO)<^tqn3B6$_O^4op0Gxy~bkwxA=!oOD0(5uHoX}nTMq-Bd;94(5rScStnI^delvG zU~9r)WMXZ_g((fyPwFz&(`kF|`Mp6*KMHaBICO2mU%Q*c&ojx#%cNo*K6nSlN`f29 z{%lSj?o#nX2!YPE;Sdf89(G?TZVp5PK`i!jQ(traE2(4A!Xw3_rC9ugiX%&h(NW;= zs*4l|IXX0x#6X3wN*_Mq>3Pub!WgMd(t=~LKcf zzcFsMARA!64-FFl~w<6SLZKcCBEH2u`oSa_V1)b z>GlZ{BNxe{sZf?b$DHoszyGVDXlIhZuvrt#H@n4rr^d7o8&gB78@_PT=S0YX0olg# zK=~w$aV>Q{>ci&L;FIVXr0~ylPO0g~;Emh$E&qKQWwmTr-PXK|@NN3EaQJ6{}n{a2RoAL6OMv@$%~x__k)D~=i8 z*d|lEWt~L5e}c*uU;j+P&(nGrZRMY%P~WMSEPU4!zGWBe&{F{#baL`>oEv%FFES5F z6lY5#d>k9>!AFp#7UUH-w>q7N3HtKhA&c1vpbbFB=YRj31=7283Lc%<*I;me9_8&B z$~$k(iBj?uL@%YHh8kFtf6G1?)6l^u$vQyhcMn>e{wDoWevFJi^nYXzLZl7+5Ak^T zy^VJ>L%NlxYg~9%n6!5KH)$w`zWOBC7&dl>x-Ktx2|1&=gOjmq zvc#?w5$E`v4#(|#hr`M)+8jI#BU2Bqt%%S zwRx}+mJ?S!zS}4yiUwrAXH(~3(P1qmD{t(O&@veC;#zNWfGOqP0}X;x(!6OQLlP@8 zDAl~wjOH-5Q`j;`a6Gan_pG3XEk)@ue^k}I+5f9vLv#ET%hFLt}UrlgJL+M%g zC+=ImlMS&<6B> zMnf|_gtm9(R%V#7NQhO}p~?MS2;1o&(G@K7M9o>o&HWb})E;P!wGi-Qn3C%GNlk66RG z=stxx4FM~`RW+Rw-BRPgJd_F(_VmD;pX*`cs)@eDt-*FWi z+t*_$RIb-HPx|p#p!mDFh1~(uMjJY?uK@=5G^mPXh9$2=2UNYVZV4UKhv&_9HU*(CSlaX#s!_5~%VE6Ti=_0asT`YcZrM zF)6{aUT$}L7+<#$qvT<)L6|$;=CL3(*slLGy#w`iIi?Ba1E(4lZNM|#Ne+0&2IlfF zN%Et7<^<}g39B;fu*C=xfsMSZ+#ZezCG4(rMI=@eP8o(jCf-`!`5G0;C~PSzDynWv z|DPy7m|^*FP6GXpHSR*q+5R*9%SrJ)?2&IthLjpHO4reW zq7Z|`l?zx2A?A4W6eiS^L;)PUzmq;3;eexYtI0%-iN@3sIMw{TP<3^w?|*Y)jR6Vw zl&gf3u2L0>a|<2LU#LjUw2r}LLB3UD>-pJ?LBSGFG7$u?#^?7OsoLmnHK?GREE9f% z*qAW_iOslj?Gta7#4)(?3M+FQ?!nvm-le)c(ycDwdVHbL-j(Z|(s_~S{23`VU-`19 zA@V8X3$l9uvF9srP!{o-uRM2IJ&T=jmHvRwYz>r87s};(Iv4U1wtp>*)=z4?`W!3x z10^g01{+?>NL7Jd$XO)^7I9%KcDUz84*8`Y>_i^VVKx3C&G*a+?lr;-H&1r@C!UFK z|3yFfJpN=h${>gNzxh0E1SNm97dT)9lc`rhqNGZ`J$PU5I@aUMt{B*YhTnj{)cg^eM(&yl<|PgcP%<*Ix~3#igGY!ll7JB4;W3!;-vg-k;aq`CMq~~U z1S@a;XaMKqKlbw-%>8O?@Ln{8Pe_M}_+N>7w1M$_e%QJ?A?+d5|LA+$sSn_v@Ko2c z__i?3;tyN^3pEKN^9USaR?ZeYkjuHwl{-hD>BCbCjYrJcaqQoX%h{PP_9-}nEps%6 z+>9JCE2L;@D#;iU&q3DvT!mTA^W&_CQ>~JLp1~i_esR1}!2K4!0i#~W1FqnH*79}4 zI}Koh1Z!yQE6e?4%&NMAmV&oG-J6sG;nzfwxpb?OZ>h5V+{pEcEzi%xtZcU0m#WJE z3e4?AO0(zAUOaxyrrl%Juu~3l4|MTy-Cq+n(L4J@|XpggV=9D-q{t-gJ^r&KL zuF7S(yxTm#$1i(w)XkH+4O6XmA7n8;gF>y~IBi zz@MaojsE*nRdh7O+^!~GVS8`G%qA9*#{zG#`6Md+dfS|KFKu4;Y|BNgl*M`mA87XmW7PddV)8AuIp zT0c%S*>xf7J6&Uj$!jhQ)7~5%nfWIvkN3ECOkdVlEBEm|j+0<0Sp+^n2r0de!yC4j z0bYps%V`m7re&pK5(J?1UgSqcWjm+BY#;Z;v{ZyuoSNpXpEN93kuto7GQNGnldk>I zk^pD(Mk-QvsXuDj49h;7mLAkA{o%eDd(of=+xh=P0@edsOPBu}_+RrvK44-@Lr-2a zwR7+ivr|{&N$DVDiMCaK>)3giF@w?~cxh_;>yNj6^u{4hkEj1=JK1ls;&UXG7R2>v z?5=*UlMQpp$Q8xeQcHzFT-i2rtWiu&iD)nT;0;A01#G}EOa{>6U>s8oOfjW_-MK;l M00000000000Bpc$!vFvP literal 0 HcmV?d00001 diff --git a/apps/editor/public/icons/eyebrow-vent/scoop.webp b/apps/editor/public/icons/eyebrow-vent/scoop.webp new file mode 100644 index 0000000000000000000000000000000000000000..9883a9215559d47fc080045ecc78b363b53abac9 GIT binary patch literal 6434 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 literal 0 HcmV?d00001 diff --git a/apps/editor/public/icons/eyebrow-vent/slant-box.webp b/apps/editor/public/icons/eyebrow-vent/slant-box.webp new file mode 100644 index 0000000000000000000000000000000000000000..3d313d22ee32c5691f3c18b701592e46eeab683b GIT binary patch literal 9472 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 literal 0 HcmV?d00001 diff --git a/apps/editor/public/icons/gutter/box.webp b/apps/editor/public/icons/gutter/box.webp new file mode 100644 index 0000000000000000000000000000000000000000..cab5f7fb82c4387fcd009d7ae9c0cdd7c1d19748 GIT binary patch literal 8622 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 AmH+?% literal 0 HcmV?d00001 diff --git a/apps/editor/public/icons/gutter/half-round.webp b/apps/editor/public/icons/gutter/half-round.webp new file mode 100644 index 0000000000000000000000000000000000000000..9b9a8ddff25a6b72ceaa02522bf4862e918cb144 GIT binary patch literal 8138 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 literal 0 HcmV?d00001 diff --git a/apps/editor/public/icons/gutter/k-style.webp b/apps/editor/public/icons/gutter/k-style.webp new file mode 100644 index 0000000000000000000000000000000000000000..8945d195d63bc4bf659402d71c8959fa096dafef GIT binary patch literal 8628 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 G0002sC)$bt literal 0 HcmV?d00001 diff --git a/apps/editor/public/icons/ridge-vent/metal.webp b/apps/editor/public/icons/ridge-vent/metal.webp new file mode 100644 index 0000000000000000000000000000000000000000..4960384eaaf71fdbca34e51a3ed8fd42657e23c1 GIT binary patch literal 8198 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%0AtVLnlKB(| zTuro0|JLN~hyMMqP~MJ>VwM50vqwEn0pC12A=|KZXG zHU^WrCJ$&*;^r@dunJ%GKfOhOc@sT<|BVpQfB&*Fr~V04q}709K{E0If@S0a}*u0<|sR1!`Ns3a|hG{`@ew0000002Lm{)_Gys zG!8mRV;8{uUNYu?`pDncgMiL)*hqkQ>G2_4O+~j*5{>cs|CI__y&Lh{ct(G;j z9UsqSUu}3-tV++V49_TmP@Cchz9xF}M2}qgPuvU7TqD2*OC4kXtiLjoD#(_OOzlcu zcdnG!+(w3vU>?6I&cqt(v=Rz%7j!xWZ|+EW8I#jx#no)ky%jb>?t0JB=l_G6aD2v- z${106Wu}mmF#O%YXVGcnB!DgK7UAcGo~uXj!eJJz^H2#-8$7ZJ>@GHz06|yVHi|Yjd1CyJAMFL=A8~2 zhUIlqB;~Ay^5}7K*()S_w}(__#wD*@v!~GuxSIr8UJuX_wA5cy=;y(`=;emMFD$<7 zZua+bGsWJ1;eWqr^sADgyi)?C^{oER08y5>spJ0Bsmyvm$D`l2B&p3vUQX4E=;I<& za?Oz~A-NR(ZJvcZ5#NvNUqsNs+oz6O(#SwgCGYPnV*6~eb5r} zY3@(=rGz=wAliwNm8aj`eT}Q3ZIa!eh?mYDvzK%NX&=-OY*WE3hYaWKX(?YvWfZiZ z_XZ#cPtr2!)Nt;hGbKTiySd|E zy_^GUEJ)B*QyABR@uVO+C;l8dq>MOT|1!B@`j@Bbg?~C87H~d)RO$=$FE?_L6#sbs zJv!u#qPPT5N$=3}EUiEuKn=Vm2?qH|mQzFX40o0FR&dT@@cX*z{tYPC?JjA3(vCr2 zYyS*!g(c?KnJD(a{qyo2#DYi}5xq&sljs2=*K_^8bPAKyDy^i`|5CGTHZV?cZ(MWA zXz26~{E4h=K>>}du*{X7z??E;?=8^d#hq41S9=dCD4#4|niMfN)=V6VxWAw8+1R$a zbzVs>NU>o!xN8Z-F45;yS5ZMV>P%*ev|@p4V^g+CGO@s~KW{Ly)p<^FD0jXZ*yjV9 zc@@fqa^h$(-T<6>&t-HQ5{r~Fz#G+d{lhYN=n4FKuXT8!qx^L2^-86B@^Zw79W7nL zMv`g~CGCd>$WR9q4ulB$^QiHlurSg!ti$tiRMLF-i60R6)v8{$zJ3TjeYyVc+xof# zirVc^MML#{B&Ic&*YV$7R=af9KM8z5&%oUOhC*J2P9x}6pFTuAvUjW#*=%6$9S8s0 zL%80aLjRab{Np7P&W)SC>(x0=wQe+PCEI&U>$dG=*U2;av_Dh0s>~KX=s@uw`ue?7 zr}?hG2lV2C?vfL(m zRR8fMV3@L3^Fkllz)kJQKv22yue&AnYO}8mKZf)3cKh{aB6{O>rM!N0s3>C9o+X%8 zRMqOUw3$~=`++qqp{c^*jIKf_vD+=}_}W)8gN*4Jyl(hlj8^**dAH;Vt;gt{ZltIr zE?y3K&Z}oi+}{8XHmGl?!CLJKsE7OXVI2maD8~1|zeLDP(DR_5Q8!;!{O*q)72KOm ztSVu#MGiR^q>B^VqVQIN%_T{Yjdh!&TLxh!ic&VJ9}$^M8kq_El07n+T2ufR{Vlf( zhuoFTbY}9>EA`@os4|yYr)k{m%Bb*bcSv`@q_Gy+-Hls;#V8lN@S%r~J0(*5vF~`! zIr8ej^}x&{@a@4;5?g5Ys*IZcnO9KOFg2lB0en)RMX{ zk|kaN(>TZ{tz^|;2Tg^uLPKWMbGtLqvMMrVq(DX-1FRMybg>1nkH(~5#)(rG*SkAL z68<35AZ$I!stK@Z3egMCpp9v`?{#6GPzP|JUS{?wj2>u7Kx};>ss)vl@M)NNYva-K z%G!*p49PwvK$DQ$FT5LFq+R>)O{7`-KFH+g7ksj=P2RLg(qtx6lUR<6QBj8dNWhqz zc=(e3Ev7f(&4nV(ku<4Hf=RhtGzN1nq-K}9Anl}C@O%-9X2X>+YnuET3Z~L@JaJ7% zd@eJe-C}JgKxeZxjfg`xKnC7YAfKO%ESYq-bwt0t?(iT4%c6LkGqtlX@^FuKtVdmT z=&;e|4;0jE2_TREM(pv(y~&Ok7rBe0YgIl4MLHE`k8rEr;NPFG9N;m((}F>Y#Rh5@ zQYTaa0Ib_U1iKx7OTr_b#Z_=qG*Q#oJl3-cqI)z$JKeK&vxop+K=5D4KK(V`kboJ4 z`PC|&E@oI8dVB(>n(LVuQPrVeKzJP(%;Rv;AqsQj?yPC4$bzW|hf|Z5mS)#cP^o`R|@B)*uel5Bd_5Kbk8HsHx zl%wyOIoM%YoJ}-^8Q}h$Z+3Iz^2KN?b`eppheeQ&z-dQMQk*70sA}6()z|Q{pqXun zC&SVMg?4hMO_=+NtjPrAx!9WM8=n5>opL}X!(;rcrJeYBm~>;m(}A@^H~-?|nkwFZ z^B~QU0EDpK;mYbwIm1(y@+D$1#E2^{rc8#41N=PCxjbr2?r0-H*zS~4A^*Z`1^=fD z!EU(hF=bc)W~KOGmNV=0xQ9ED}{0Dh3IGEzLZTU*sw_AHIu2M8l%W(vP+2*Z+rnD*XN!>ymbolu`EF z*vkXYIY(=AZ4>gud7{b43SK#5RBCVF)%EE|rvq6$hu~Z4@K`Egh%H%bgXUt|Xr1o# zFMwE|$)bYc5g4fz&2}?r$@{Wzu9n#sDJdH+_xr_CLf<;ynL+ zy77$Lk$#YWerL{0_Fn%_*QvG_`4_g4Mm_DlL;~u>aF$1+= z3%GIO4)y&JFZ~LWUo6?+OSa3ukhdrhNqZDZk=a^U7CF4Q5!QtPV#v~9^d%+Z3PcO- zo<{N&I7T_5jG(%Dpfe9TzgG}YqgxNZ5C@*XKTU=v_&<`Jqpa*-g;$IZtM`4{R8z?Q z14u-|)IjVs2*t?$8DO|k*C8d-?46CKE6bsWAa%eS8;*Fu9$QXt+$UDa=HWCoM!e4n z-{BqzFNkT3v3jJ{lK-Be-A8=wb6Q6+XoEUD#9+k%cjGqAif8Tbe04k9Te*-o;gq%> zSa&y*0*FAc>ixIJZe*3~UwY~li8z-(Yfi=!+|0tSx4Ri3 zVjbwINr`(EeE*R=lva_mp(l3upKm`cNa7I3|G$&$`0YM3GeA~9sx?VC-uLc=(p6iP zZ>=)Z;B-6pQ~(Hx-)QIrU+RXS{m=o&;0eV$5>%1DMnx}GO`z7{SlhpQKJ-7>e`#IS z1L#b2U_-!RIjt9M8r4yUp$3;1pt=Qp{DSKWf1lu%x8WL4?ce-#E*&O2v=4rKeVUfy zlqoc+Tyv$TEM8nvoG`v1x9k!o((ZI}0U|34A%_4HIvSGB$#WzQKmL{xPr>zVuycA~cwKui%JW4U@lGe9W7x!s!+7UW6Y6>VaD3 zKE4H|5Kk2)>7O`wZ6lat5JdJh*Qaz5!pQJs_inA9aQ~(J7f168q^oyKLQ%JM&h!g+yd|o2c*vg9C00a8=)8<)Ymo?CJ!#q-uhMWG7f``y zA}9(R4tk}*#n*i{J?%ytYuM#@&C}y8=hd1e2WWo157IYD%irF@pzhR0=N#~PGU9vk zgK_O>o0xFlP*WeIYM}8mvr=#RdskU<9MY+oos*-iw>+96V>S=Pg(B!PjSTkCF#z8Z zKN&WNj0}QGwrGw#Guj)vK5~`K6Ftv^9nvyj!BZ8v5?Tx0AO0zkVV_A{+W?zq@1P&= z&lzoz+QhZ*+C3StarxZe^_aoCgkEl(qcC^1xP!6r_z)Bj8vzzIe@q(4}b9H}5 zDg8cWQpc)u0cJ~?2bE+hn|$myZ5cr)J#>akWU}c!^kJp5VN$2k?=e$bI%{~E0$_XW zZ4q82NNTmTaye!rKF)VL3muob4Lnk0_ip@#Jb&~D$#wJSiRwf0FP4$ld1fXgjN%*o;Qd08CD%M@A737P zj`_Ybw^`U4+^twpOzH%ddEO=u@EC<)0s8VN8r6|Ypu+CS!8$5LpG!!f1dJ?vTYJ znF5z$ESEk5V`NJO?g9B?NYksSW%n@snDeGTD!gx2Q-I@?Y|wN?e_3Zt(O5HXfZ{G%_$lu}jDukYSZ+3o+)iI%k;&9j}_G^kg8| zCs`NVbv0VH=wEBIi*ay~Rusc^V89ZcOTAN945&~9Kf`8Grb=dc@^X$h&|WVT=6(@0 zvP5*&6?D(CFaR5qM~=d8$(FquqK51h8QxN%f z^yNgI6w5Xd+!3<0DWRzch{I`tBJB4bR^?inesIgPIWUH2U{i0^2rTMpo?a4gqryO` zh*IrpqCKSiB$K(g9ZWpH0LEWqZb>@#hQ#nqGY(jV4{IP2O!bRfg2$4%6h$4jg;c~8 zg$1kY0*rn}%B2GRNE#J~^Xd+wkN4|2l`%B_f9J!cBr@)N15k;l+wLeYU@z&x-`4`e znt$wG`OJC3`Av$ROyk*-`u2xxDLyW;1?^&EUfg8h>i`M=Koi$&(mq_NES%HZMW_fH zibE4~Ol9|U;O7H$GykEv->j!WbI=Nl3R5rqY?&5Idd#b1HcH9lwCDmc{O)CwwG#pv zuF*rgKx%r;O@r*O{a`+$-kyipSr$}l6QzC1`ps%1jR^g{_56tmj9s^D$V-2rLMHLj syfaYW0I#SuoD!c$g0Knr8{5!h4Q#72mjK>w{+Q?(000000000001EUp&;S4c literal 0 HcmV?d00001 diff --git a/apps/editor/public/icons/ridge-vent/shingled.webp b/apps/editor/public/icons/ridge-vent/shingled.webp new file mode 100644 index 0000000000000000000000000000000000000000..5419597dd6b0c1d54060dddae7f25e02b6c6fd2f GIT binary patch literal 9568 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 K000000001L;l<hQz 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 previewProp({ width: v })} onCommit={(v) => handleUpdate({ width: v })} @@ -172,7 +195,6 @@ export default function BoxVentPanel() { /> previewProp({ depth: v })} onCommit={(v) => handleUpdate({ depth: v })} @@ -183,8 +205,7 @@ export default function BoxVentPanel() { value={node.depth} /> previewProp({ height: v })} onCommit={(v) => handleUpdate({ height: v })} @@ -194,6 +215,11 @@ export default function BoxVentPanel() { unit="m" value={node.height} /> + + + {/* 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 @@ -201,7 +227,6 @@ export default function BoxVentPanel() { {node.style === 'cap' && ( previewProp({ hoodOverhang: v })} onCommit={(v) => handleUpdate({ hoodOverhang: v })} @@ -270,7 +295,7 @@ export default function BoxVentPanel() { value={node.capHeight ?? 0.07} /> previewProp({ capGap: v })} @@ -311,7 +336,6 @@ export default function BoxVentPanel() { /> previewProp({ hoodOverhang: v })} onCommit={(v) => handleUpdate({ hoodOverhang: v })} @@ -323,21 +347,64 @@ export default function BoxVentPanel() { /> )} +

+ Detail dimensions stay within the housing height and width. +

- + + previewProp({ flashingWidth: v })} + onCommit={(v) => handleUpdate({ flashingWidth: v })} + /> +

+ Flashing extends beyond the housing on every side. Set to zero to omit it. +

+ {segment && ( + + + handleUpdate({ + position: [ + node.position[0], + roofY(node.position[0], node.position[2]), + node.position[2], + ], + }) + } + /> + + handleUpdate({ + rotation: getDownSlopeYaw(node.position[0], node.position[2], segment), + }) + } + /> + + )} +
+ previewProp({ - position: [v, node.position[1] ?? 0, node.position[2] ?? 0], + position: [v, roofY(v, node.position[2]) + roofOffset, node.position[2]], }) } onCommit={(v) => handleUpdate({ - position: [v, node.position[1] ?? 0, node.position[2] ?? 0], + position: [v, roofY(v, node.position[2]) + roofOffset, node.position[2]], }) } precision={2} @@ -347,27 +414,30 @@ export default function BoxVentPanel() { value={node.position[0] ?? 0} /> previewProp({ - position: [node.position[0] ?? 0, v, node.position[2] ?? 0], + position: [ + node.position[0], + roofY(node.position[0], node.position[2]) + v, + node.position[2], + ], }) } onCommit={(v) => handleUpdate({ - position: [node.position[0] ?? 0, v, node.position[2] ?? 0], + position: [ + node.position[0], + roofY(node.position[0], node.position[2]) + v, + node.position[2], + ], }) } precision={2} restoreOnCommit={false} step={0.05} unit="m" - value={node.position[1] ?? 0} + value={roofOffset} /> previewProp({ - position: [node.position[0] ?? 0, node.position[1] ?? 0, v], + position: [node.position[0], roofY(node.position[0], v) + roofOffset, v], }) } onCommit={(v) => handleUpdate({ - position: [node.position[0] ?? 0, node.position[1] ?? 0, v], + position: [node.position[0], roofY(node.position[0], v) + roofOffset, v], }) } precision={2} diff --git a/packages/nodes/src/box-vent/parametrics.ts b/packages/nodes/src/box-vent/parametrics.ts index b9d1d16563..0a1c69ba05 100644 --- a/packages/nodes/src/box-vent/parametrics.ts +++ b/packages/nodes/src/box-vent/parametrics.ts @@ -1,4 +1,5 @@ import type { ParametricDescriptor } from '@pascal-app/core' +import { boxVentDimensionPatch } from './dimensions' import type { BoxVentNode } from './schema' /** @@ -12,6 +13,7 @@ export const boxVentParametrics: ParametricDescriptor = { // of relying on the generic inspector — which only knows about Move // for `capabilities.movable` kinds and routes those through the // grid-plane mover. + derive: boxVentDimensionPatch, customPanel: () => import('./panel'), groups: [ { @@ -20,7 +22,7 @@ export const boxVentParametrics: ParametricDescriptor = { { key: 'style', kind: 'enum', - options: ['standard', 'low-profile', 'dome'], + options: ['box', 'cap', 'dome'], display: 'segmented', }, ], @@ -31,7 +33,86 @@ export const boxVentParametrics: ParametricDescriptor = { { key: 'width', kind: 'number', unit: 'm', min: 0.15, max: 1000, step: 0.01 }, { key: 'depth', kind: 'number', unit: 'm', min: 0.15, max: 1000, step: 0.01 }, { key: 'height', kind: 'number', unit: 'm', min: 0.05, max: 1000, step: 0.01 }, - { key: 'hoodOverhang', kind: 'number', unit: 'm', min: 0, max: 0.12, step: 0.005 }, + { + key: 'hoodOverhang', + kind: 'number', + unit: 'm', + min: 0, + step: 0.005, + visibleIf: (n) => n.style !== 'box', + }, + ], + }, + { + label: 'Installation', + fields: [ + { + key: 'flashingWidth', + label: 'Flashing extension', + kind: 'number', + unit: 'm', + min: 0, + step: 0.01, + }, + ], + }, + { + label: 'Box details', + fields: [ + { + key: 'baseInset', + kind: 'number', + unit: 'm', + min: 0, + visibleIf: (n) => n.style === 'box', + }, + { + key: 'baseHeight', + kind: 'number', + unit: 'm', + min: 0.005, + visibleIf: (n) => n.style === 'box', + }, + { + key: 'cornerBevel', + kind: 'number', + unit: 'm', + min: 0, + visibleIf: (n) => n.style === 'box', + }, + ], + }, + { + label: 'Cap details', + fields: [ + { + key: 'capHeight', + kind: 'number', + unit: 'm', + min: 0.005, + visibleIf: (n) => n.style === 'cap', + }, + { + key: 'capGap', + label: 'Vent opening', + kind: 'number', + unit: 'm', + min: 0, + visibleIf: (n) => n.style === 'cap', + }, + { key: 'topTaper', kind: 'number', min: 0, max: 1, visibleIf: (n) => n.style === 'cap' }, + ], + }, + { + label: 'Dome details', + fields: [ + { + key: 'domeCurvature', + kind: 'number', + min: 0.3, + max: 1.5, + visibleIf: (n) => n.style === 'dome', + }, ], }, ], diff --git a/packages/nodes/src/box-vent/placement.test.ts b/packages/nodes/src/box-vent/placement.test.ts new file mode 100644 index 0000000000..e7af9736d9 --- /dev/null +++ b/packages/nodes/src/box-vent/placement.test.ts @@ -0,0 +1,69 @@ +import { expect, test } from 'bun:test' +import { RoofNode, RoofSegmentNode } from '@pascal-app/core' +import { getRoofOuterSurfaceFrameAtPoint } from '@pascal-app/viewer' +import { BoxVentNode, boxVentDefinition, resolveBoxVentPlacement } from './index' + +const segment = RoofSegmentNode.parse({ width: 8, depth: 6, roofType: 'gable' }) +const vent = BoxVentNode.parse(boxVentDefinition.defaults()) + +test('placement snaps both coordinates and seats the vent on the finished roof', () => { + const placed = resolveBoxVentPlacement(segment, vent, 0.13, 1.37, { + gridStep: 0.25, + alignDownslope: true, + }) + expect(placed.position[0]).toBe(0.25) + expect(placed.position[2]).toBe(1.25) + expect(placed.position[1]).toBeCloseTo( + getRoofOuterSurfaceFrameAtPoint(segment, 0.25, 1.25).point.y, + ) + expect(placed.valid).toBe(true) + expect(resolveBoxVentPlacement(segment, vent, 0.13, 1.37).position[0]).toBe(0.13) +}) + +test('vents face downslope on opposite faces while moves preserve a custom rotation and roof offset', () => { + const front = resolveBoxVentPlacement(segment, vent, 0, 1, { alignDownslope: true }) + const back = resolveBoxVentPlacement(segment, vent, 0, -1, { alignDownslope: true }) + expect(Math.abs(front.rotation - back.rotation)).toBeCloseTo(Math.PI) + const moved = resolveBoxVentPlacement(segment, { ...vent, rotation: 0.7 }, 1, 2, { + roofOffset: 0.1, + }) + expect(moved.rotation).toBe(0.7) + expect(moved.position[1]).toBeCloseTo( + getRoofOuterSurfaceFrameAtPoint(segment, 1, 2).point.y + 0.1, + ) +}) + +test('the whole flashing footprint must fit, including ridges, eaves and trimmed edges', () => { + expect(resolveBoxVentPlacement(segment, vent, 0, 0).valid).toBe(false) + expect(resolveBoxVentPlacement(segment, vent, 0, 3.5).valid).toBe(false) + const trimmed = RoofSegmentNode.parse({ ...segment, trim: { right: 2 } }) + expect(resolveBoxVentPlacement(trimmed, vent, 1.9, 1).valid).toBe(false) + expect(resolveBoxVentPlacement(trimmed, vent, 1.5, 1).valid).toBe(true) + const flat = RoofSegmentNode.parse({ + roofType: 'flat', + width: 8, + depth: 6, + overhang: 0, + gableOverhang: 0, + wallThickness: 0, + }) + expect(resolveBoxVentPlacement(flat, { ...vent, flashingWidth: 0 }, 3.7, 0).valid).toBe(true) + expect(resolveBoxVentPlacement(flat, { ...vent, flashingWidth: 0.2 }, 3.7, 0).valid).toBe(false) +}) + +test('the 2D footprint includes the same flashing extent as the model', () => { + const roof = RoofNode.parse({}) + const host = RoofSegmentNode.parse({ parentId: roof.id, roofType: 'flat' }) + const geometry = boxVentDefinition.floorplan!(vent, { + parent: host, + children: [], + siblings: [], + resolve: () => roof as never, + }) + expect(geometry?.kind).toBe('group') + if (geometry?.kind !== 'group') throw new Error('Missing floorplan') + const plate = geometry.children[0]! + if (plate.kind !== 'polygon') throw new Error('Missing flashing footprint') + const expected = vent.width / 2 + vent.hoodOverhang + (vent.flashingWidth ?? 0) + expect(Math.max(...plate.points.map((point) => point[0]))).toBeCloseTo(expected) +}) diff --git a/packages/nodes/src/box-vent/placement.ts b/packages/nodes/src/box-vent/placement.ts new file mode 100644 index 0000000000..0fd2596926 --- /dev/null +++ b/packages/nodes/src/box-vent/placement.ts @@ -0,0 +1,50 @@ +import { type BoxVentNode, difference, type Ring, type RoofSegmentNode } from '@pascal-app/core' +import { getRoofOuterSurfaceFrameAtPoint } from '@pascal-app/viewer' +import * as THREE from 'three' +import { roofFootprintInsideTrim } from '../shared/roof-footprint-trim' +import { + getDownSlopeYaw, + getRoofSurfaceFaceBoundsAt, + surfaceQuatFromNormal, +} from '../shared/roof-surface' + +export function resolveBoxVentPlacement( + segment: RoofSegmentNode, + node: BoxVentNode, + x: number, + z: number, + options: { gridStep?: number; roofOffset?: number; alignDownslope?: boolean } = {}, +) { + const step = options.gridStep ?? 0 + if (step > 0) { + x = Math.round(x / step) * step + z = Math.round(z / step) * step + } + const frame = getRoofOuterSurfaceFrameAtPoint(segment, x, z) + const rotation = options.alignDownslope ? getDownSlopeYaw(x, z, segment) : node.rotation + const quat = surfaceQuatFromNormal(frame.normal, new THREE.Quaternion()).multiply( + new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), rotation), + ) + const extra = + Math.max(0, node.flashingWidth ?? 0) + + (node.style === 'box' ? 0 : Math.max(0, node.hoodOverhang ?? 0.04)) + const hw = node.width / 2 + extra + const hd = node.depth / 2 + extra + const footprint: Ring = [ + [-hw, -hd], + [hw, -hd], + [hw, hd], + [-hw, hd], + ].map(([px, pz]) => { + const point = new THREE.Vector3(px, 0, pz).applyQuaternion(quat) + return [x + point.x, z + point.z] + }) + const face = getRoofSurfaceFaceBoundsAt(segment, x, z).polygon + return { + position: [x, frame.point.y + (options.roofOffset ?? 0), z] as [number, number, number], + rotation, + valid: + roofFootprintInsideTrim(segment, footprint) && + difference(footprint, face, { throwOnError: true }).length === 0, + } +} diff --git a/packages/nodes/src/box-vent/preview.tsx b/packages/nodes/src/box-vent/preview.tsx index 566e0c5eaa..2b6f26b9a9 100644 --- a/packages/nodes/src/box-vent/preview.tsx +++ b/packages/nodes/src/box-vent/preview.tsx @@ -20,7 +20,21 @@ const BoxVentPreview = ({ node, invalid }: { node: BoxVentNode; invalid?: boolea // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. const geometry = useMemo( () => buildBoxVentGeometry(node), - [node.width, node.depth, node.height, node.hoodOverhang, node.style], + [ + node.width, + node.depth, + node.height, + node.hoodOverhang, + node.style, + node.flashingWidth, + node.topTaper, + node.capHeight, + node.capGap, + node.domeCurvature, + node.baseInset, + node.baseHeight, + node.cornerBevel, + ], ) const material = useMemo( @@ -41,14 +55,9 @@ const BoxVentPreview = ({ node, invalid }: { node: BoxVentNode; invalid?: boolea const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry]) - useEffect( - () => () => { - geometry.dispose() - edgesGeometry.dispose() - material.dispose() - }, - [geometry, edgesGeometry, material], - ) + useEffect(() => () => geometry.dispose(), [geometry]) + useEffect(() => () => edgesGeometry.dispose(), [edgesGeometry]) + useEffect(() => () => material.dispose(), [material]) return ( @@ -60,7 +69,12 @@ const BoxVentPreview = ({ node, invalid }: { node: BoxVentNode; invalid?: boolea }} /> - + ) diff --git a/packages/nodes/src/box-vent/renderer.tsx b/packages/nodes/src/box-vent/renderer.tsx index 83e078df5b..8cea3f59bf 100644 --- a/packages/nodes/src/box-vent/renderer.tsx +++ b/packages/nodes/src/box-vent/renderer.tsx @@ -13,13 +13,14 @@ import { createMaterial, createMaterialFromPresetRef, createSurfaceRoleMaterial, + getRoofOuterSurfaceFrameAtPoint, resolveMaterialRef, useNodeEvents, useViewer, } from '@pascal-app/viewer' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' -import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' +import { surfaceQuatFromNormal } from '../shared/roof-surface' import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip' import { buildBoxVentGeometry } from './geometry' @@ -82,6 +83,7 @@ const BoxVentRenderer = ({ node: storeNode }: { node: BoxVentNode }) => { const geometry = useMemo( () => buildBoxVentGeometry(node), [ + node.flashingWidth, node.style, node.width, node.depth, @@ -106,7 +108,11 @@ const BoxVentRenderer = ({ node: storeNode }: { node: BoxVentNode }) => { // ran along segment-local Z. const surfaceQuat = useMemo(() => { if (!segment) return new THREE.Quaternion() - const normal = getAnalyticalNormal(node.position[0] ?? 0, node.position[2] ?? 0, segment) + const normal = getRoofOuterSurfaceFrameAtPoint( + segment, + node.position[0] ?? 0, + node.position[2] ?? 0, + ).normal return surfaceQuatFromNormal(normal, new THREE.Quaternion()) }, [segment, node.position[0], node.position[2]]) diff --git a/packages/nodes/src/box-vent/tool.test.tsx b/packages/nodes/src/box-vent/tool.test.tsx new file mode 100644 index 0000000000..ee8a472c74 --- /dev/null +++ b/packages/nodes/src/box-vent/tool.test.tsx @@ -0,0 +1,91 @@ +import { expect, test } from 'bun:test' +import { + BuildingNode, + emitter, + LevelNode, + nodeRegistry, + RoofNode, + RoofSegmentNode, + registerNode, + sceneRegistry, + useScene, +} from '@pascal-app/core' +import { useEditor, usePlacementPreview } from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { act, create } from '@react-three/test-renderer' +import * as THREE from 'three' +import { boxVentDefinition, buildBoxVentGeometry } from './index' + +test('box-vent placement matches its preview, honors grid mode and blocks unsupported drops', async () => { + const restoreRegistry = nodeRegistry._snapshot() + if (!nodeRegistry.get('box-vent')) registerNode(boxVentDefinition) + const savedEditor = useEditor.getState() + useEditor.setState({ mode: 'build', tool: 'box-vent', gridSnapStep: 0.25 }) + useEditor.getState().setSnappingMode('item', 'grid') + const savedScene = useScene.getState() + const savedSelection = useViewer.getState().selection + const building = BuildingNode.parse({}) + const level = LevelNode.parse({ parentId: building.id }) + const roof = RoofNode.parse({ parentId: level.id }) + const segment = RoofSegmentNode.parse({ parentId: roof.id, width: 20, depth: 20 }) + roof.children = [segment.id] + level.children = [roof.id] + building.children = [level.id] + useScene.setState({ + nodes: { [building.id]: building, [level.id]: level, [roof.id]: roof, [segment.id]: segment }, + rootNodeIds: [building.id], + dirtyNodes: new Set(), + readOnly: false, + }) + useViewer.getState().setSelection({ buildingId: building.id, levelId: level.id }) + sceneRegistry.nodes.set(segment.id, new THREE.Group()) + const { default: Tool } = await boxVentDefinition.tool!() + const renderer = await create() + try { + const event = { + node: roof, + position: [0.13, 3, 3.13] as [number, number, number], + stopPropagation: () => {}, + } + await act(async () => emitter.emit('roof:move', event)) + const preview = usePlacementPreview.getState().node + expect(preview?.type).toBe('box-vent') + if (preview?.type !== 'box-vent') throw new Error('Missing box-vent preview') + await act(async () => emitter.emit('roof:click', event)) + const placed = Object.values(useScene.getState().nodes).find((node) => node.type === 'box-vent') + expect(placed).toBeDefined() + if (placed?.type !== 'box-vent') throw new Error('Missing box vent') + expect(preview.position).toEqual(placed.position) + expect(placed.position[0]).toBe(0.25) + expect(placed.position[2]).toBe(3.25) + expect(placed.flashingWidth).toBe(0.05) + expect(preview.id).not.toBe(placed.id) + const previewGeometry = buildBoxVentGeometry(preview)! + const placedGeometry = buildBoxVentGeometry(placed)! + previewGeometry.computeBoundingBox() + placedGeometry.computeBoundingBox() + expect(previewGeometry.boundingBox!.equals(placedGeometry.boundingBox!)).toBe(true) + previewGeometry.dispose() + placedGeometry.dispose() + expect(usePlacementPreview.getState().node).toBeNull() + const outside = { ...event, position: [30, 3, 3] as [number, number, number] } + await act(async () => emitter.emit('roof:click', outside)) + expect( + Object.values(useScene.getState().nodes).filter((n) => n.type === 'box-vent'), + ).toHaveLength(1) + await act(async () => + emitter.emit('roof:click', { ...outside, nativeEvent: { altKey: true } as never }), + ) + expect( + Object.values(useScene.getState().nodes).filter((n) => n.type === 'box-vent'), + ).toHaveLength(2) + } finally { + restoreRegistry() + useEditor.setState(savedEditor) + await renderer.unmount() + sceneRegistry.nodes.delete(segment.id) + useScene.setState(savedScene) + useViewer.getState().setSelection(savedSelection) + usePlacementPreview.getState().clear() + } +}) diff --git a/packages/nodes/src/box-vent/tool.tsx b/packages/nodes/src/box-vent/tool.tsx index 27dd2da61f..11fe72166f 100644 --- a/packages/nodes/src/box-vent/tool.tsx +++ b/packages/nodes/src/box-vent/tool.tsx @@ -1,160 +1,163 @@ 'use client' +import { type AnyNodeId, BoxVentNode, emitter, type RoofEvent, useScene } from '@pascal-app/core' 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' + isGridSnapActive, + isMagneticSnapActive, + triggerSFX, + useEditor, + usePlacementPreview, +} from '@pascal-app/editor' +import { getRoofOuterSurfaceFrameAtPoint, useViewer } from '@pascal-app/viewer' +import { useEffect, useMemo, useState } from 'react' import * as THREE from 'three' +import { roofSegmentLocalToBuildingLocal } from '../shared/relative-roof-drag' import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' -import { getAnalyticalNormal, getDownSlopeYaw, surfaceQuatFromNormal } from '../shared/roof-surface' +import { surfaceQuatFromNormal } from '../shared/roof-surface' import { clearRoofSurfacePlacementGuides, - publishRoofSurfacePlacementGuides, - roofSurfaceFootprintFromNode, + publishRoofSurfaceNodePlacementGuides, + snapRoofSurfaceNodeTarget, } from '../shared/roof-surface-placement-guides' import { boxVentDefinition } from './definition' +import { resolveBoxVentPlacement } from './placement' 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 = () => { +export default function 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(null) - const [previewYaw, setPreviewYaw] = useState(0) - const [previewRotation, setPreviewRotation] = 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: previewRotation, - }), - [previewRotation], + const seed = useMemo( + () => BoxVentNode.parse({ ...boxVentDefinition.defaults(), name: 'Box Vent' }), + [], ) + const [preview, setPreview] = useState<{ + node: BoxVentNode + position: [number, number, number] + normal: THREE.Quaternion + yaw: number + valid: boolean + } | null>(null) 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 clear = () => { + setPreview(null) + if (usePlacementPreview.getState().node?.id === seed.id) + usePlacementPreview.getState().clear() + clearRoofSurfacePlacementGuides() } - - 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 (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== sx || prev[1] !== sz)) { - triggerSFX('sfx:grid-snap') - lastSnapRef.current = [sx, sz] + const resolve = (event: RoofEvent) => { + const hit = resolveRoofSegmentHit(event.node, ...event.position) + if (!hit) return null + const force = event.nativeEvent?.altKey === true + let placement = resolveBoxVentPlacement(hit.segment, seed, hit.localX, hit.localZ, { + gridStep: !force && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0, + alignDownslope: true, + }) + if (!force && isMagneticSnapActive()) { + const target = snapRoofSurfaceNodeTarget({ + target: { + ...hit, + hit, + localX: placement.position[0], + localY: placement.position[1], + localZ: placement.position[2], + }, + node: { ...seed, rotation: placement.rotation }, + }) + placement = resolveBoxVentPlacement(hit.segment, seed, target.localX, target.localZ, { + alignDownslope: true, + }) } - - 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)) - setPreviewRotation(getDownSlopeYaw(hit.localX, hit.localZ, hit.segment)) - setPreviewPos(worldToBuildingLocal(wx, wy, wz)) - publishRoofSurfacePlacementGuides({ - roof: event.node as RoofNode, + return { hit, placement, force } + } + const show = (event: RoofEvent) => { + const resolved = resolve(event) + if (!resolved) { + clear() + return + } + const { hit, placement, force } = resolved + const node = { + ...seed, + position: placement.position, + rotation: placement.rotation, + parentId: hit.segment.id, + roofSegmentId: hit.segment.id, + } + const normal = getRoofOuterSurfaceFrameAtPoint( + hit.segment, + placement.position[0], + placement.position[2], + ).normal + setPreview({ + node, + position: roofSegmentLocalToBuildingLocal(hit.segment.id, placement.position), + normal: surfaceQuatFromNormal(normal, new THREE.Quaternion()), + yaw: event.node.rotation + hit.segment.rotation, + valid: placement.valid || force, + }) + usePlacementPreview.getState().set(node, hit.segment) + publishRoofSurfaceNodePlacementGuides({ + roof: event.node, segment: hit.segment, - center: [hit.localX, hit.localY, hit.localZ], - footprint: roofSurfaceFootprintFromNode({ - ...previewNode, - rotation: getDownSlopeYaw(hit.localX, hit.localZ, hit.segment), - }), + center: placement.position, + node, }) 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', + const place = (event: RoofEvent) => { + const resolved = resolve(event) + if (!resolved) return + const { hit, placement, force } = resolved + if (!placement.valid && !force) { + show(event) + return + } + const node = BoxVentNode.parse({ + ...seed, + id: undefined, roofSegmentId: hit.segment.id, - position: [hit.localX, hit.localY, hit.localZ], - rotation: getDownSlopeYaw(hit.localX, hit.localZ, hit.segment), + position: placement.position, + rotation: placement.rotation, }) - state.createNode(vent, hit.segment.id as AnyNodeId) - state.dirtyNodes.add(hit.segment.id as AnyNodeId) - setSelection({ selectedIds: [vent.id] }) + useScene.getState().createNode(node, hit.segment.id as AnyNodeId) + useViewer.getState().setSelection({ selectedIds: [node.id] }) triggerSFX('sfx:item-place') - clearRoofSurfacePlacementGuides() + clear() event.stopPropagation() } - - emitter.on('roof:move', updatePreview) - emitter.on('roof:enter', updatePreview) - emitter.on('roof:click', onClick) - + emitter.on('roof:move', show) + emitter.on('roof:enter', show) + emitter.on('roof:leave', clear) + emitter.on('roof:click', place) + emitter.on('tool:cancel', clear) return () => { - emitter.off('roof:move', updatePreview) - emitter.off('roof:enter', updatePreview) - emitter.off('roof:click', onClick) - clearRoofSurfacePlacementGuides() + emitter.off('roof:move', show) + emitter.off('roof:enter', show) + emitter.off('roof:leave', clear) + emitter.off('roof:click', place) + emitter.off('tool:cancel', clear) + clear() } - }, [activeBuildingId, setSelection, previewNode]) + }, [activeBuildingId, seed]) return ( <> } + ghost={} onInvalidTarget={() => { - setPreviewPos(null) - setPreviewSurfaceQuat(null) + setPreview(null) + usePlacementPreview.getState().clear() clearRoofSurfacePlacementGuides() }} /> - {activeBuildingId && previewPos && previewSurfaceQuat && ( - - - - + {preview && ( + + + + @@ -162,5 +165,3 @@ const BoxVentTool = () => { ) } - -export default BoxVentTool diff --git a/packages/nodes/src/chimney/__tests__/geometry.test.ts b/packages/nodes/src/chimney/__tests__/geometry.test.ts index 41e6e7267a..f8c1ffd4cb 100644 --- a/packages/nodes/src/chimney/__tests__/geometry.test.ts +++ b/packages/nodes/src/chimney/__tests__/geometry.test.ts @@ -1,7 +1,6 @@ import { describe, expect, test } from 'bun:test' import type { RoofSegmentNode } from '@pascal-app/core' -import { buildChimneyGeometry, flueXPositions } from '../geometry' -import { ChimneyNode } from '../schema' +import { buildChimneyGeometry, ChimneyNode, flueXPositions } from '../index' const fixtureSegment = (): RoofSegmentNode => ({ diff --git a/packages/nodes/src/chimney/definition.ts b/packages/nodes/src/chimney/definition.ts index ea1d9a0135..1ebd7707f8 100644 --- a/packages/nodes/src/chimney/definition.ts +++ b/packages/nodes/src/chimney/definition.ts @@ -344,11 +344,6 @@ const WHITE_MATERIAL = { * `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 = { kind: 'chimney', @@ -356,6 +351,7 @@ export const chimneyDefinition: NodeDefinition = { schema: ChimneyNode, category: 'structure', surfaceRole: 'wall', + snapProfile: 'item', defaults: () => { const stub = ChimneyNodeSchema.parse({ @@ -406,6 +402,7 @@ export const chimneyDefinition: NodeDefinition = { tool: () => import('./tool'), toolHints: [ { key: 'Left click', label: 'Place chimney on roof' }, + { key: 'Alt', label: 'Place freely' }, { key: 'Esc', label: 'Cancel' }, ], diff --git a/packages/nodes/src/chimney/geometry.ts b/packages/nodes/src/chimney/geometry.ts index 540b8d6807..3accc1bf72 100644 --- a/packages/nodes/src/chimney/geometry.ts +++ b/packages/nodes/src/chimney/geometry.ts @@ -1,4 +1,5 @@ import { type ChimneyNode, getActiveRoofHeight, type RoofSegmentNode } from '@pascal-app/core' +import { getRoofOuterSurfaceFrameAtPoint } from '@pascal-app/viewer' import * as THREE from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' @@ -7,14 +8,6 @@ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js * 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. @@ -46,7 +39,7 @@ function buildSmoothCylinder( yTop: number, rBot: number, rTop: number, - segments = 24, + segments = 48, ): THREE.BufferGeometry { const h = Math.max(1e-4, yTop - yBot) const cy = (yTop + yBot) / 2 @@ -91,7 +84,7 @@ export function buildChimneyGeometry(node: ChimneyNode, segment: RoofSegmentNode let cricket: THREE.BufferGeometry | null = null if (node.cricketStyle !== 'none' && node.bodyShape !== 'round') { - cricket = buildCricketGeometry(node, baseY) + cricket = buildCricketGeometry(node, segment) } let bands: THREE.BufferGeometry | null = null @@ -324,7 +317,7 @@ function buildFluesGeometry(node: ChimneyNode, capTopY: number): THREE.BufferGeo // ─── Cricket ───────────────────────────────────────────────────────── // Water-shedding wedge on the up-slope side of the chimney. -function buildCricketGeometry(node: ChimneyNode, baseY: number): THREE.BufferGeometry { +function buildCricketGeometry(node: ChimneyNode, segment: RoofSegmentNode): THREE.BufferGeometry { const w = node.width const d = node.depth const cL = Math.max(0.1, node.cricketLength) @@ -332,7 +325,8 @@ function buildCricketGeometry(node: ChimneyNode, baseY: number): THREE.BufferGeo const slopeSign = node.cricketSide === 'back' ? -1 : 1 const sZ = slopeSign * (d / 2) const sZFar = sZ + slopeSign * cL - const peakY = baseY + cH + const baseY = 0 + const peakY = cH const slopeLen = Math.hypot(cL, cH) const positions: number[] = [] @@ -412,6 +406,20 @@ function buildCricketGeometry(node: ChimneyNode, baseY: number): THREE.BufferGeo // Right side triangle pushTri(v1, v5, v2, uvRight.v1, uvRight.v5, uvRight.v2) + // Fit the cricket bottom to the finished roof at every vertex. + const c = Math.cos(node.rotation), + sn = Math.sin(node.rotation) + for (let i = 0; i < positions.length; i += 3) { + const x = positions[i]!, + z = positions[i + 2]! + positions[i + 1] = + positions[i + 1]! + + getRoofOuterSurfaceFrameAtPoint( + segment, + node.position[0] + x * c + z * sn, + node.position[2] - x * sn + z * c, + ).point.y + } const geo = buildBufferGeometry(positions, uvs) applyNodeTransform(geo, node) geo.computeVertexNormals() diff --git a/packages/nodes/src/chimney/index.ts b/packages/nodes/src/chimney/index.ts index 2e6ba44533..93bd7892c7 100644 --- a/packages/nodes/src/chimney/index.ts +++ b/packages/nodes/src/chimney/index.ts @@ -1,3 +1,4 @@ export { chimneyDefinition } from './definition' export { buildChimneyGeometry, flueXPositions } from './geometry' +export { resolveChimneyPlacement } from './placement' export { ChimneyNode } from './schema' diff --git a/packages/nodes/src/chimney/move-tool.tsx b/packages/nodes/src/chimney/move-tool.tsx index b8e891c6ce..c8ec67c302 100644 --- a/packages/nodes/src/chimney/move-tool.tsx +++ b/packages/nodes/src/chimney/move-tool.tsx @@ -13,6 +13,8 @@ import { import { consumePlacementDragRelease, copyCollectionIds, + isGridSnapActive, + isMagneticSnapActive, markToolCancelConsumed, triggerSFX, useEditor, @@ -20,16 +22,13 @@ import { import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' -import { - createRelativeRoofDrag, - type RelativeRoofDragTarget, - snapRelativeRoofDragTarget, -} from '../shared/relative-roof-drag' +import { createRelativeRoofDrag, type RelativeRoofDragTarget } from '../shared/relative-roof-drag' import { clearRoofSurfacePlacementGuides, publishRoofSurfaceNodePlacementGuides, snapRoofSurfaceNodeTarget, } from '../shared/roof-surface-placement-guides' +import { resolveChimneyPlacement } from './placement' import ChimneyPreview from './preview' const tmpMatrix = new THREE.Matrix4() @@ -75,7 +74,7 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => { ...node, id: 'chimney_preview' as never, position: [0, 0, 0], - rotation: 0, + rotation: node.rotation, }), [node], ) @@ -127,11 +126,18 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => { const resolveSnappedTarget = (event: RoofEvent): RelativeRoofDragTarget | null => { const rawTarget = roofDrag.resolve(event) if (!rawTarget) return null - return snapRoofSurfaceNodeTarget({ - target: snapRelativeRoofDragTarget(rawTarget, event.nativeEvent?.shiftKey === true), + const bypass = event.nativeEvent?.altKey === true + const placement = resolveChimneyPlacement( + rawTarget.segment, node, - bypass: event.nativeEvent?.shiftKey === true, - }) + rawTarget.localX, + rawTarget.localZ, + !bypass && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0, + ) + const target = { ...rawTarget, localX: placement.position[0], localZ: placement.position[2] } + return !bypass && isMagneticSnapActive() + ? snapRoofSurfaceNodeTarget({ target, node }) + : target } const clearTarget = () => { @@ -149,7 +155,7 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => { const sx = Math.round(target.localX * 20) / 20 const sz = Math.round(target.localZ * 20) / 20 const prev = lastSnapRef.current - if (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== sx || prev[1] !== sz)) { + if (event.nativeEvent?.altKey !== true && (!prev || prev[0] !== sx || prev[1] !== sz)) { triggerSFX('sfx:grid-snap') lastSnapRef.current = [sx, sz] } @@ -173,6 +179,11 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => { if (committed) return const target = lastTarget ?? resolveSnappedTarget(event) if (!target) return + if ( + !resolveChimneyPlacement(target.segment, node, target.localX, target.localZ).valid && + event.nativeEvent?.altKey !== true + ) + return committed = true const state = useScene.getState() @@ -218,21 +229,7 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => { // one tracked transaction. Otherwise undo reverts the chimney but // leaves the children arrays inconsistent with its parentId. useScene.temporal.getState().resume() - if (reparenting) { - const oldSeg = state.nodes[prevSegmentId!] as RoofSegmentNode | undefined - if (oldSeg) { - state.updateNode(prevSegmentId!, { - children: (oldSeg.children ?? []).filter((id) => id !== node.id), - }) - } - const newSeg = state.nodes[targetSegmentId] as RoofSegmentNode | undefined - if (newSeg && !(newSeg.children ?? []).includes(node.id)) { - state.updateNode(targetSegmentId, { - children: [...(newSeg.children ?? []), node.id], - }) - } - state.dirtyNodes.add(prevSegmentId!) - } + if (reparenting) state.dirtyNodes.add(prevSegmentId!) state.updateNode(node.id as AnyNodeId, { roofSegmentId: target.segment.id, parentId: target.segment.id, @@ -334,7 +331,11 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => { return ( - + ) diff --git a/packages/nodes/src/chimney/panel.tsx b/packages/nodes/src/chimney/panel.tsx index 529bd989fc..08adec0baa 100644 --- a/packages/nodes/src/chimney/panel.tsx +++ b/packages/nodes/src/chimney/panel.tsx @@ -114,16 +114,6 @@ export default function ChimneyPanel() { 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) @@ -177,7 +167,7 @@ export default function ChimneyPanel() { // 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 localPt = new Vector3() const worldPt = localPt.applyMatrix4(chimneyObj.matrixWorld) return { x: worldPt.x, z: worldPt.z } } @@ -189,7 +179,7 @@ export default function ChimneyPanel() { 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) + return ancestorWorldY } const { x: worldX_now, z: worldZ_now } = computeChimneyWorldPos() const worldRotation_now = computeChimneyWorldRotation() @@ -202,7 +192,7 @@ export default function ChimneyPanel() { const state = useScene.getState() const worldPt = new Vector3(wx, 0, wz) for (const candidate of Object.values(state.nodes)) { - if (candidate?.type !== 'roof-segment') continue + if (candidate?.type !== 'roof-segment' || candidate.parentId !== segment?.parentId) continue const seg = candidate as RoofSegmentNode const segObj = sceneRegistry.nodes.get(seg.id) if (!segObj) continue @@ -339,23 +329,39 @@ export default function ChimneyPanel() { // Match the current store node against the preset table so the return ( - 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 ?? ''} - /> +
+ {CHIMNEY_PRESET_KEYS.map((key) => ( + + ))} +
+

+ Styles preserve dimensions and finishes. +

@@ -369,7 +375,6 @@ export default function ChimneyPanel() { /> previewProp({ width: v })} onCommit={(v) => commitProp({ width: v })} @@ -382,7 +387,6 @@ export default function ChimneyPanel() { {(node.bodyShape ?? 'square') !== 'round' && ( previewProp({ depth: v })} onCommit={(v) => commitProp({ depth: v })} @@ -436,7 +440,6 @@ export default function ChimneyPanel() { previewProp({ heightAboveRidge: v })} onCommit={(v) => commitProp({ heightAboveRidge: v })} @@ -460,7 +463,7 @@ export default function ChimneyPanel() { /> - + - +
{CHIMNEY_TYPE_OPTIONS.filter((option) => { // Cricket and Panels both rely on a flat face — hide them for @@ -563,9 +566,9 @@ export default function ChimneyPanel() { { label: 'Flat', value: 'flat' }, { label: 'Stepped', value: 'stepped' }, ]} - value={node.capShape ?? 'sloped'} + value={node.cap ? node.capShape : 'none'} /> - {(node.capShape ?? 'sloped') !== 'none' && ( + {node.cap && node.capShape !== 'none' && ( <> { + const segment = RoofSegmentNode.parse({ width: 8, depth: 6 }) + const node = ChimneyNode.parse({ width: 1, depth: 1, capOverhang: 0.2 }) + expect(resolveChimneyPlacement(segment, node, 0.36, 0.64, 0.5).position).toEqual([0.5, 0, 0.5]) + expect(resolveChimneyPlacement(segment, node, 0.36, 0.64).position).toEqual([0.36, 0, 0.64]) + expect(resolveChimneyPlacement(segment, node, 0, 0).valid).toBe(true) + expect(resolveChimneyPlacement(segment, node, 4.2, 0).valid).toBe(false) +}) + +test('cricket bottom follows the finished roof at a rotated chimney position', () => { + const segment = RoofSegmentNode.parse({ width: 8, depth: 6, pitch: 30 }) + const node = ChimneyNode.parse({ position: [1, 0, 1], rotation: 0.4, cricketStyle: 'simple' }) + const geometry = buildChimneyGeometry(node, segment) + const positions = geometry.cricket!.getAttribute('position') + const c = Math.cos(node.rotation), + s = Math.sin(node.rotation) + for (let i = 0; i < 6; i++) { + const x = positions.getX(i), + z = positions.getZ(i) + const y = getRoofOuterSurfaceFrameAtPoint(segment, 1 + x * c + z * s, 1 - x * s + z * c).point.y + expect(positions.getY(i)).toBeCloseTo(y, 4) + } + for (const part of Object.values(geometry)) part?.dispose() +}) diff --git a/packages/nodes/src/chimney/placement.ts b/packages/nodes/src/chimney/placement.ts new file mode 100644 index 0000000000..e276098c61 --- /dev/null +++ b/packages/nodes/src/chimney/placement.ts @@ -0,0 +1,45 @@ +import { + type ChimneyNode, + getRoofSegmentVisibleTopBounds, + type Ring, + type RoofSegmentNode, +} from '@pascal-app/core' +import { roofFootprintInsideTrim } from '../shared/roof-footprint-trim' + +export function resolveChimneyPlacement( + segment: RoofSegmentNode, + node: ChimneyNode, + x: number, + z: number, + gridStep = 0, +) { + if (gridStep > 0) { + x = Math.round(x / gridStep) * gridStep + z = Math.round(z / gridStep) * gridStep + } + const extra = Math.max( + node.cap && node.capShape !== 'none' ? node.capOverhang : 0, + node.bandStyle !== 'none' ? node.bandExtent : 0, + node.shoulderStyle !== 'none' ? node.shoulderExtent : 0, + ) + const hw = node.width / 2 + extra + const hd = (node.bodyShape === 'round' ? node.width : node.depth) / 2 + extra + const c = Math.cos(node.rotation), + s = Math.sin(node.rotation) + const footprint: Ring = [ + [-hw, -hd], + [hw, -hd], + [hw, hd], + [-hw, hd], + ].map(([px, pz]) => [x + px! * c + pz! * s, z - px! * s + pz! * c]) + const bounds = getRoofSegmentVisibleTopBounds(segment) + return { + position: [x, 0, z] as [number, number, number], + valid: + roofFootprintInsideTrim(segment, footprint) && + footprint.every( + ([px, pz]) => + px >= bounds.minX && px <= bounds.maxX && pz >= bounds.minZ && pz <= bounds.maxZ, + ), + } +} diff --git a/packages/nodes/src/chimney/preview.tsx b/packages/nodes/src/chimney/preview.tsx index e50296c051..6404048a6f 100644 --- a/packages/nodes/src/chimney/preview.tsx +++ b/packages/nodes/src/chimney/preview.tsx @@ -48,40 +48,7 @@ const ChimneyPreview = ({ const material = invalid ? invalidGhostMaterial : ghostMaterial const effectiveSegment = segment ?? RoofSegmentSchema.parse({}) - // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. - const geo = useMemo( - () => buildChimneyGeometry(node, effectiveSegment), - [ - effectiveSegment.wallHeight, - effectiveSegment.pitch, - effectiveSegment.roofType, - effectiveSegment.width, - effectiveSegment.depth, - node.width, - node.depth, - node.heightAboveRidge, - node.bodyShape, - node.shoulderStyle, - node.shoulderHeight, - node.shoulderExtent, - node.cap, - node.capShape, - node.capOverhang, - node.capThickness, - node.flueCount, - node.flueShape, - node.flueHeight, - node.flueDiameter, - node.flueSpacing, - node.cricketStyle, - node.cricketSide, - node.cricketLength, - node.cricketHeight, - node.position[0], - node.position[2], - node.rotation, - ], - ) + const geo = useMemo(() => buildChimneyGeometry(node, effectiveSegment), [node, effectiveSegment]) useEffect( () => () => { @@ -89,13 +56,15 @@ const ChimneyPreview = ({ geo.cap?.dispose() geo.flues?.dispose() geo.cricket?.dispose() + geo.bands?.dispose() }, [geo], ) return ( - + {}} /> + {geo.bands && {}} />} {geo.cap && {}} />} {geo.flues && {}} />} {geo.cricket && {}} />} diff --git a/packages/nodes/src/chimney/renderer.tsx b/packages/nodes/src/chimney/renderer.tsx index 3623104870..a4d54d637b 100644 --- a/packages/nodes/src/chimney/renderer.tsx +++ b/packages/nodes/src/chimney/renderer.tsx @@ -28,12 +28,6 @@ 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(null!) diff --git a/packages/nodes/src/chimney/tool.tsx b/packages/nodes/src/chimney/tool.tsx index a6f91cc6f9..0633f09a86 100644 --- a/packages/nodes/src/chimney/tool.tsx +++ b/packages/nodes/src/chimney/tool.tsx @@ -10,7 +10,13 @@ import { sceneRegistry, useScene, } from '@pascal-app/core' -import { triggerSFX, usePlacementPreview } from '@pascal-app/editor' +import { + isGridSnapActive, + isMagneticSnapActive, + triggerSFX, + useEditor, + usePlacementPreview, +} from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' @@ -20,8 +26,10 @@ import { clearRoofSurfacePlacementGuides, publishRoofSurfacePlacementGuides, roofSurfaceFootprintFromNode, + snapRoofSurfaceNodeTarget, } from '../shared/roof-surface-placement-guides' import { chimneyDefinition } from './definition' +import { resolveChimneyPlacement } from './placement' import ChimneyPreview from './preview' /** @@ -55,6 +63,7 @@ const ChimneyTool = () => { // segment's footprint. const [hitLocal, setHitLocal] = useState<[number, number, number] | null>(null) const [previewSegment, setPreviewSegment] = useState(null) + const [valid, setValid] = useState(true) const lastSnapRef = useRef<[number, number] | null>(null) const previewNode = useMemo( @@ -94,13 +103,35 @@ const ChimneyTool = () => { const sx = Math.round(wx * 20) / 20 const sz = Math.round(wz * 20) / 20 const prev = lastSnapRef.current - if (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== sx || prev[1] !== sz)) { + if (event.nativeEvent?.altKey !== true && (!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 placement = resolveChimneyPlacement( + hit.segment, + previewNode, + hit.localX, + hit.localZ, + event.nativeEvent?.altKey !== true && isGridSnapActive() + ? useEditor.getState().gridSnapStep + : 0, + ) + if (event.nativeEvent?.altKey !== true && isMagneticSnapActive()) { + const snapped = snapRoofSurfaceNodeTarget({ + target: { ...hit, hit, localX: placement.position[0], localZ: placement.position[2] }, + node: previewNode, + }) + Object.assign( + placement, + resolveChimneyPlacement(hit.segment, previewNode, snapped.localX, snapped.localZ), + ) + } + hit.localX = placement.position[0] + hit.localZ = placement.position[2] + setValid(placement.valid || event.nativeEvent?.altKey === true) const xform = computeSegmentXform(hit.segment.id) if (!xform) return @@ -133,6 +164,29 @@ const ChimneyTool = () => { event.position[2], ) if (!hit) return + const placement = resolveChimneyPlacement( + hit.segment, + previewNode, + hit.localX, + hit.localZ, + event.nativeEvent?.altKey !== true && isGridSnapActive() + ? useEditor.getState().gridSnapStep + : 0, + ) + if (event.nativeEvent?.altKey !== true && isMagneticSnapActive()) { + const snapped = snapRoofSurfaceNodeTarget({ + target: { ...hit, hit, localX: placement.position[0], localZ: placement.position[2] }, + node: previewNode, + }) + Object.assign( + placement, + resolveChimneyPlacement(hit.segment, previewNode, snapped.localX, snapped.localZ), + ) + } + hit.localX = placement.position[0] + hit.localZ = placement.position[2] + setValid(placement.valid || event.nativeEvent?.altKey === true) + if (!placement.valid && event.nativeEvent?.altKey !== true) return const state = useScene.getState() const chimney = ChimneyNode.parse({ @@ -152,11 +206,21 @@ const ChimneyTool = () => { event.stopPropagation() } + const clear = () => { + setHitLocal(null) + setPreviewSegment(null) + usePlacementPreview.getState().clear() + clearRoofSurfacePlacementGuides() + } + emitter.on('roof:leave', clear) + emitter.on('tool:cancel', clear) emitter.on('roof:move', updatePreview) emitter.on('roof:enter', updatePreview) emitter.on('roof:click', onClick) return () => { + emitter.off('roof:leave', clear) + emitter.off('tool:cancel', clear) emitter.off('roof:move', updatePreview) emitter.off('roof:enter', updatePreview) emitter.off('roof:click', onClick) @@ -188,7 +252,11 @@ const ChimneyTool = () => { // geometry (built with `position[0,2] = 0`) lands under the cursor. - + )} diff --git a/packages/nodes/src/cupola/__tests__/geometry.test.ts b/packages/nodes/src/cupola/__tests__/geometry.test.ts index aff63ab5e7..67a3e93214 100644 --- a/packages/nodes/src/cupola/__tests__/geometry.test.ts +++ b/packages/nodes/src/cupola/__tests__/geometry.test.ts @@ -1,6 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { buildCupolaGeometry } from '../geometry' -import { CupolaNode } from '../schema' +import { buildCupolaGeometry, CupolaNode } from '../index' function allFinite(geo: { getAttribute: (n: string) => { array: ArrayLike } }): boolean { const arr = geo.getAttribute('position').array @@ -58,3 +57,18 @@ describe('buildCupolaGeometry', () => { expect(allFinite(geo)).toBe(true) }) }) + +for (const roofStyle of ['dome', 'pyramid'] as const) { + test(`${roofStyle} keeps the configured height with or without a finial`, () => { + for (const finial of [true, false]) { + for (const width of [0.2, 0.8, 4]) { + const node = CupolaNode.parse({ roofStyle, finial, width, depth: width, height: 0.3 }) + const geometry = buildCupolaGeometry(node) + geometry.computeBoundingBox() + expect(geometry.boundingBox!.max.y).toBeCloseTo(node.height, 6) + expect(geometry.boundingBox!.min.y).toBeCloseTo(0, 6) + geometry.dispose() + } + } + }) +} diff --git a/packages/nodes/src/cupola/definition.ts b/packages/nodes/src/cupola/definition.ts index f49574a1a6..d8d7b897b6 100644 --- a/packages/nodes/src/cupola/definition.ts +++ b/packages/nodes/src/cupola/definition.ts @@ -104,7 +104,7 @@ const cupolaHandles: HandleDescriptor[] = [ /** * Cupola — a louvered roof lantern. Parented to a `roof-segment`; position * is segment-local; rotation rotates it around the segment's vertical axis - * after the slope tilt is applied. Same composition as the box vent (custom + * while the body stays upright. Same composition as the box vent (custom * renderer, pure geometry builder, no system) — see box-vent's definition * for the rationale on why roof accessories need a custom renderer. */ @@ -149,11 +149,13 @@ export const cupolaDefinition: NodeDefinition = { preview: () => import('./preview'), tool: () => import('./tool'), + snapProfile: 'item', affordanceTools: { move: () => import('./move-tool'), }, toolHints: [ { key: 'Left click', label: 'Place cupola on roof' }, + { key: 'Alt', label: 'Place freely' }, { key: 'Esc', label: 'Cancel' }, ], @@ -167,6 +169,6 @@ export const cupolaDefinition: NodeDefinition = { mcp: { description: - 'A louvered cupola (roof lantern) on a roof segment. Roof style: dome / pyramid, optional finial. Parametric width/depth/height.', + 'A louvered cupola (roof lantern) on a roof segment. Roof style: dome / pyramid, optional finial. Upright body with a roof-fitting base. Parametric width/depth/height.', }, } diff --git a/packages/nodes/src/cupola/floorplan.ts b/packages/nodes/src/cupola/floorplan.ts index 4c0d4941d0..aeeb9f99a4 100644 --- a/packages/nodes/src/cupola/floorplan.ts +++ b/packages/nodes/src/cupola/floorplan.ts @@ -61,8 +61,8 @@ export function buildCupolaFloorplan( const fillOpacity = showSelectedChrome ? 0.55 : 0.7 const lineWidth = showSelectedChrome ? 0.03 : 0.02 - const hw = Math.max(node.width, 0.1) / 2 - const hd = Math.max(node.depth, 0.1) / 2 + const hw = Math.max(node.width, 0.2) / 2 + const hd = Math.max(node.depth, 0.2) / 2 const cornOvh = Math.min(hw, hd) * 0.22 const rect = (halfX: number, halfZ: number): FloorplanPoint[] => [ @@ -107,5 +107,28 @@ export function buildCupolaFloorplan( pointerEvents: 'none', }) + if (node.roofStyle === 'pyramid') { + for (const corner of outer) + children.push({ + kind: 'polyline', + points: [corner, toPlan(0, 0)], + stroke, + strokeWidth: lineWidth * 0.7, + pointerEvents: 'none', + }) + } else { + children.push({ + kind: 'polyline', + points: Array.from({ length: 49 }, (_, i) => + toPlan( + (hw + cornOvh) * Math.cos((i * Math.PI) / 24), + (hd + cornOvh) * Math.sin((i * Math.PI) / 24), + ), + ), + stroke, + strokeWidth: lineWidth * 0.7, + pointerEvents: 'none', + }) + } return { kind: 'group', children } } diff --git a/packages/nodes/src/cupola/geometry.ts b/packages/nodes/src/cupola/geometry.ts index 82c78f5876..09b3f73c86 100644 --- a/packages/nodes/src/cupola/geometry.ts +++ b/packages/nodes/src/cupola/geometry.ts @@ -1,4 +1,4 @@ -import type { CupolaNode } from '@pascal-app/core' +import type { CupolaNode, RoofSegmentNode } from '@pascal-app/core' import * as THREE from 'three' import { copyUvToSecondaryChannel, @@ -7,6 +7,8 @@ import { planarMetricUvs, } from '../shared/primitive-uv' +import { cupolaMount } from './placement' + export const CUPOLA_MATERIAL_INDEX = { base: 0, body: 1, @@ -33,7 +35,10 @@ export const CUPOLA_MATERIAL_INDEX = { * Pure: no React, no scene access, no store mutation. Safe for unit tests, * the placement preview, and the move-tool ghost. */ -export function buildCupolaGeometry(node: CupolaNode): THREE.BufferGeometry { +export function buildCupolaGeometry( + node: CupolaNode, + segment?: RoofSegmentNode, +): THREE.BufferGeometry { const w = Math.max(0.2, node.width) const d = Math.max(0.2, node.depth) const h = Math.max(0.3, node.height) @@ -44,7 +49,7 @@ export function buildCupolaGeometry(node: CupolaNode): THREE.BufferGeometry { const baseH = h * 0.05 const bodyH = h * 0.42 const corniceH = h * 0.06 - const roofH = h * 0.32 + const roofH = h * (node.finial ? 0.32 : 0.47) const baseTop = baseH const bodyTop = baseTop + bodyH const corniceTop = bodyTop + corniceH @@ -59,13 +64,36 @@ export function buildCupolaGeometry(node: CupolaNode): THREE.BufferGeometry { const uv: number[] = [] // Base plinth (slightly wider than the body) — closed box. - addBox(p, n, uv, hw + baseOvh, hd + baseOvh, 0, baseTop) + if (segment) { + const { perimeter } = cupolaMount(segment, node) + for (let i = 0; i < perimeter.length; i++) { + const a = perimeter[i]!, + b = perimeter[(i + 1) % perimeter.length]! + pushQuad(p, n, uv, a, b, [b[0], 0, b[2]], [a[0], 0, a[2]], [a[0] + b[0], 0, a[2] + b[2]]) + } + } + addBox(p, n, uv, hw + baseOvh, hd + baseOvh, 0, baseTop * 0.45) + addBox(p, n, uv, hw + baseOvh * 0.55, hd + baseOvh * 0.55, baseTop * 0.45, baseTop) const baseEnd = p.length / 3 - // Body — closed box; the louvers are applied as relief on its walls. - addBox(p, n, uv, hw, hd, baseTop, bodyTop) + // Recessed backing, corner posts and rails frame the louver openings. + addBox(p, n, uv, hw * 0.91, hd * 0.91, baseTop, bodyTop) + const post = Math.min(w, d) * 0.07 + for (const x of [-hw + post / 2, hw - post / 2]) { + for (const z of [-hd + post / 2, hd - post / 2]) { + const start = p.length + addBox(p, n, uv, post / 2, post / 2, baseTop, bodyTop) + for (let i = start; i < p.length; i += 3) { + p[i]! += x + p[i + 2]! += z + } + } + } + addBox(p, n, uv, hw, hd, baseTop, baseTop + bodyH * 0.08) + addBox(p, n, uv, hw, hd, bodyTop - bodyH * 0.08, bodyTop) const bodyEnd = p.length / 3 // Cornice — overhanging slab the roof sits on. - addBox(p, n, uv, hw + cornOvh, hd + cornOvh, bodyTop, corniceTop) + addBox(p, n, uv, hw + cornOvh * 0.55, hd + cornOvh * 0.55, bodyTop, bodyTop + corniceH * 0.45) + addBox(p, n, uv, hw + cornOvh, hd + cornOvh, bodyTop + corniceH * 0.45, corniceTop) const corniceEnd = p.length / 3 // Louvered slats on all four body faces. @@ -83,13 +111,18 @@ export function buildCupolaGeometry(node: CupolaNode): THREE.BufferGeometry { // Finial: a short post topped by a ball. if (node.finial) { - const ballR = Math.min(w, d) * 0.05 + const ballR = Math.min(Math.min(w, d) * 0.05, h * 0.035) const postR = ballR * 0.45 - const postTop = apexY + roofH * 0.18 + const postTop = h - ballR * 1.7 addCylinder(p, n, uv, postR, apexY, postTop) addSphere(p, n, uv, ballR, postTop + ballR * 0.7) } + if (segment) { + const mount = cupolaMount(segment, node) + const rise = mount.seatY - mount.centerY + for (let i = 1; i < p.length; i += 3) p[i]! += rise + } const geo = new THREE.BufferGeometry() geo.setAttribute('position', new THREE.Float32BufferAttribute(p, 3)) geo.setAttribute('normal', new THREE.Float32BufferAttribute(n, 3)) @@ -127,9 +160,7 @@ function addBox( } // ─── Louvers ───────────────────────────────────────────────────────── -// Angled slats standing proud of each body face. Each slat is a double- -// sided angled quad (emitted twice with opposite hints) so it reads from -// both above and below. The solid body wall behind them blocks see-through. +// Closed angled slats sit in front of recessed backing on each face. const SLAT_COUNT = 5 @@ -155,12 +186,13 @@ function addLouvers( const bottom = y0 + margin const span = top - bottom const drop = span / (SLAT_COUNT + 1) - const proj = drop * 0.9 + const proj = Math.min(drop * 0.9, Math.min(hw, hd) * 0.08) + const thickness = Math.min(drop * 0.12, 0.008) for (const { map, halfU } of faces) { const outDir = sub(map(0, 0, 1), map(0, 0, 0)) const upOut: number[] = [outDir[0]!, 1, outDir[2]!] - const downOut: number[] = [outDir[0]!, -1, outDir[2]!] + const downOut: number[] = [-upOut[0]!, -1, -upOut[2]!] for (let k = 1; k <= SLAT_COUNT; k++) { const yTop = bottom + (span * k) / SLAT_COUNT const a = map(-halfU, yTop, 0) @@ -169,7 +201,19 @@ function addLouvers( const dd = map(-halfU, yTop - drop, proj) // Top + underside of the slat. pushQuad(p, n, uv, a, b, c, dd, upOut) - pushQuad(p, n, uv, a, b, c, dd, downOut) + const lower = [a, b, c, dd].map((q) => [q[0]!, q[1]! - thickness, q[2]!]) + pushQuad(p, n, uv, lower[0]!, lower[1]!, lower[2]!, lower[3]!, downOut) + const upper = [a, b, c, dd] + for (let j = 0; j < 4; j++) { + const k = (j + 1) % 4 + const hint = + j === 0 + ? [-outDir[0]!, 0, -outDir[2]!] + : j === 2 + ? outDir + : sub(map(j === 1 ? 1 : -1, 0, 0), map(0, 0, 0)) + pushQuad(p, n, uv, upper[j]!, upper[k]!, lower[k]!, lower[j]!, hint) + } } } } @@ -211,8 +255,8 @@ function addDomeRoof( y0: number, domeH: number, ): void { - const lng = 20 - const lat = 6 + const lng = 48 + const lat = 12 let prev = ringAt(rx, rz, y0, lng) let prevU = cumulativeProfileDistances(prev) let domeV = 0 diff --git a/packages/nodes/src/cupola/index.ts b/packages/nodes/src/cupola/index.ts index e6e855574f..458dd913a5 100644 --- a/packages/nodes/src/cupola/index.ts +++ b/packages/nodes/src/cupola/index.ts @@ -1,3 +1,4 @@ export { cupolaDefinition } from './definition' export { buildCupolaGeometry } from './geometry' +export { cupolaMount, resolveCupolaPlacement } from './placement' export { CupolaNode } from './schema' diff --git a/packages/nodes/src/cupola/move-tool.tsx b/packages/nodes/src/cupola/move-tool.tsx index 0e02936bbb..63756fd524 100644 --- a/packages/nodes/src/cupola/move-tool.tsx +++ b/packages/nodes/src/cupola/move-tool.tsx @@ -11,8 +11,12 @@ import { useScene, } from '@pascal-app/core' import { + commitFreshPlacementSubtree, consumePlacementDragRelease, + isGridSnapActive, + isMagneticSnapActive, markToolCancelConsumed, + stripPlacementMetadataFlags, triggerSFX, useEditor, } from '@pascal-app/editor' @@ -22,22 +26,23 @@ import { createRelativeRoofDrag, type RelativeRoofDragTarget, roofSegmentLocalToBuildingLocal, - snapRelativeRoofDragTarget, } from '../shared/relative-roof-drag' -import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' import { clearRoofSurfacePlacementGuides, publishRoofSurfaceNodePlacementGuides, snapRoofSurfaceNodeTarget, } from '../shared/roof-surface-placement-guides' +import { cupolaMount, resolveCupolaPlacement } from './placement' import CupolaPreview from './preview' /** - * Cupola move tool. Mirrors the box-vent move flow: the original mesh hides - * during the drag, a 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 (reparenting between segments when needed). Cancel - * restores the original transform, or deletes a freshly-cloned cupola. + * Cupola 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 MoveCupolaTool({ node }: { node: CupolaNode }) { const exitMoveMode = useCallback(() => { @@ -46,6 +51,8 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) { const [previewPos, setPreviewPos] = useState<[number, number, number] | null>(null) const [previewSurfaceQuat, setPreviewSurfaceQuat] = useState(null) + const [previewValid, setPreviewValid] = useState(true) + const [previewNode, setPreviewNode] = useState(node) const [previewYaw, setPreviewYaw] = useState(0) useEffect(() => { @@ -64,12 +71,19 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) { : {} const isNew = !!meta.isNew - const cupolaObj = sceneRegistry.nodes.get(node.id) - if (cupolaObj) cupolaObj.visible = false + const ventObj = sceneRegistry.nodes.get(node.id) + if (ventObj) ventObj.visible = false let lastSnap: [number, number] | null = null let lastTarget: RelativeRoofDragTarget | null = null let committed = false + let forcePlacement = false + const originalSegment = original.roofSegmentId + ? (useScene.getState().nodes[original.roofSegmentId as AnyNodeId] as RoofSegmentNode) + : undefined + const roofOffset = originalSegment + ? original.position[1] - cupolaMount(originalSegment, node).centerY + : 0 const roofDrag = createRelativeRoofDrag(original) const clearTarget = () => { @@ -83,11 +97,32 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) { const resolveSnappedTarget = (event: RoofEvent): RelativeRoofDragTarget | null => { const rawTarget = roofDrag.resolve(event) if (!rawTarget) return null - return snapRoofSurfaceNodeTarget({ - target: snapRelativeRoofDragTarget(rawTarget, event.nativeEvent?.shiftKey === true), + forcePlacement = event.nativeEvent?.altKey === true + let placement = resolveCupolaPlacement( + rawTarget.segment, node, - bypass: event.nativeEvent?.shiftKey === true, - }) + rawTarget.localX, + rawTarget.localZ, + { + roofOffset, + gridStep: !forcePlacement && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0, + }, + ) + let target = { + ...rawTarget, + localX: placement.position[0], + localY: placement.position[1], + localZ: placement.position[2], + } + if (!forcePlacement && isMagneticSnapActive()) { + target = snapRoofSurfaceNodeTarget({ target, node }) + placement = resolveCupolaPlacement(target.segment, node, target.localX, target.localZ, { + roofOffset, + }) + target = { ...target, localY: placement.position[1] } + } + setPreviewValid(placement.valid || forcePlacement) + return target } const updatePreview = (event: RoofEvent) => { @@ -97,19 +132,24 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) { return } lastTarget = target + setPreviewNode({ + ...node, + position: [target.localX, target.localY, target.localZ], + roofSegmentId: target.segment.id, + }) const sx = Math.round(target.localX * 20) / 20 const sz = Math.round(target.localZ * 20) / 20 if ( - event.nativeEvent?.shiftKey !== true && + !forcePlacement && + (isGridSnapActive() || isMagneticSnapActive()) && (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) ) { triggerSFX('sfx:grid-snap') lastSnap = [sx, sz] } - const normal = getAnalyticalNormal(target.localX, target.localZ, target.segment) - setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion())) + setPreviewSurfaceQuat(new THREE.Quaternion()) setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0)) setPreviewPos( roofSegmentLocalToBuildingLocal(target.segment.id, [ @@ -129,39 +169,34 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) { const onRoofClick = (event: RoofEvent) => { if (committed) return - const target = lastTarget ?? resolveSnappedTarget(event) + const target = event.node ? resolveSnappedTarget(event) : lastTarget if (!target) return + if ( + !forcePlacement && + !resolveCupolaPlacement(target.segment, node, target.localX, target.localZ, { + roofOffset, + }).valid + ) + return committed = true const targetSegmentId = target.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, { + const patch = { roofSegmentId: targetSegmentId, parentId: targetSegmentId, - position: [target.localX, target.localY, target.localZ], + position: [target.localX, target.localY, target.localZ] as [number, number, number], rotation: original.rotation, visible: true, - metadata: {}, - }) - useScene.temporal.getState().pause() + metadata: stripPlacementMetadataFlags(original.metadata) as CupolaNode['metadata'], + } + if (isNew) { + commitFreshPlacementSubtree(node.id as AnyNodeId, patch) + } else { + useScene.temporal.getState().resume() + st.updateNode(node.id as AnyNodeId, patch) + useScene.temporal.getState().pause() + } st.dirtyNodes.add(targetSegmentId) st.dirtyNodes.add(node.id as AnyNodeId) @@ -177,6 +212,8 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) { 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 @@ -237,6 +274,9 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) { emitter.off('tool:cancel', onCancel) window.removeEventListener('pointerup', onPlacementDragPointerUp) + // 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 clearRoofSurfacePlacementGuides() @@ -250,7 +290,7 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) { - + diff --git a/packages/nodes/src/cupola/panel.tsx b/packages/nodes/src/cupola/panel.tsx index 894c0b0dbf..22a51adcc4 100644 --- a/packages/nodes/src/cupola/panel.tsx +++ b/packages/nodes/src/cupola/panel.tsx @@ -3,7 +3,6 @@ import { type AnyNode, type AnyNodeId, - getActiveRoofHeight, type RoofSegmentNode, useLiveNodeOverrides, useScene, @@ -19,9 +18,10 @@ import { triggerSFX, useEditor, } from '@pascal-app/editor' -import { useViewer } from '@pascal-app/viewer' +import { getRoofOuterSurfaceFrameAtPoint, useViewer } from '@pascal-app/viewer' import { Copy, Move, Trash2 } from 'lucide-react' import { useCallback } from 'react' +import { resolveCupolaPlacement } from './placement' import type { CupolaNode } from './schema' /** @@ -95,15 +95,6 @@ export default function CupolaPanel() { 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) @@ -115,23 +106,44 @@ export default function CupolaPanel() { if (!(node && node.type === 'cupola' && selectedId)) return null + const surfaceY = (x: number, z: number) => + segment ? getRoofOuterSurfaceFrameAtPoint(segment, x, z).point.y : 0 + const offset = node.position[1] - surfaceY(node.position[0], node.position[2]) + const at = (x: number, z: number): [number, number, number] => [x, surfaceY(x, z) + offset, z] + const ridge = + segment && !['flat', 'shed', 'conical', 'barrel', 'glazed'].includes(segment.roofType) + ? resolveCupolaPlacement(segment, node, node.position[0], node.position[2], { + alignRidge: true, + }) + : null + return ( - handleUpdate({ roofStyle: v as CupolaNode['roofStyle'] })} - options={[ - { label: 'Dome', value: 'dome' }, - { label: 'Pyramid', value: 'pyramid' }, - ]} - value={node.roofStyle ?? 'dome'} - /> +
+ {(['dome', 'pyramid'] as const).map((roofStyle) => ( + + ))} +
handleUpdate({ finial: v === 'on' })} options={[ @@ -145,8 +157,7 @@ export default function CupolaPanel() { previewProp({ width: v })} onCommit={(v) => handleUpdate({ width: v })} precision={2} @@ -157,8 +168,7 @@ export default function CupolaPanel() { /> previewProp({ depth: v })} onCommit={(v) => handleUpdate({ depth: v })} precision={2} @@ -168,9 +178,8 @@ export default function CupolaPanel() { value={node.depth} /> previewProp({ height: v })} onCommit={(v) => handleUpdate({ height: v })} precision={2} @@ -181,17 +190,44 @@ export default function CupolaPanel() { /> - + +
+ + +
+

+ Upright body with a base fitted to the roof. Lantern height excludes the fitted base. +

+
+ - previewProp({ position: [v, node.position[1] ?? 0, node.position[2] ?? 0] }) - } - onCommit={(v) => - handleUpdate({ position: [v, node.position[1] ?? 0, node.position[2] ?? 0] }) - } + onChange={(v) => previewProp({ position: at(v, node.position[2]) })} + onCommit={(v) => handleUpdate({ position: at(v, node.position[2]) })} precision={2} restoreOnCommit={false} step={0.05} @@ -199,34 +235,37 @@ export default function CupolaPanel() { value={node.position[0] ?? 0} /> - previewProp({ position: [node.position[0] ?? 0, v, node.position[2] ?? 0] }) + previewProp({ + position: [ + node.position[0], + surfaceY(node.position[0], node.position[2]) + v, + node.position[2], + ], + }) } onCommit={(v) => - handleUpdate({ position: [node.position[0] ?? 0, v, node.position[2] ?? 0] }) + handleUpdate({ + position: [ + node.position[0], + surfaceY(node.position[0], node.position[2]) + v, + node.position[2], + ], + }) } precision={2} restoreOnCommit={false} step={0.05} unit="m" - value={node.position[1] ?? 0} + value={offset} /> - previewProp({ position: [node.position[0] ?? 0, node.position[1] ?? 0, v] }) - } - onCommit={(v) => - handleUpdate({ position: [node.position[0] ?? 0, node.position[1] ?? 0, v] }) - } + onChange={(v) => previewProp({ position: at(node.position[0], v) })} + onCommit={(v) => handleUpdate({ position: at(node.position[0], v) })} precision={2} restoreOnCommit={false} step={0.05} diff --git a/packages/nodes/src/cupola/placement.test.ts b/packages/nodes/src/cupola/placement.test.ts new file mode 100644 index 0000000000..ae10df49f7 --- /dev/null +++ b/packages/nodes/src/cupola/placement.test.ts @@ -0,0 +1,64 @@ +import { expect, test } from 'bun:test' +import { RoofSegmentNode } from '@pascal-app/core' +import { getRoofOuterSurfaceFrameAtPoint } from '@pascal-app/viewer' +import { buildCupolaGeometry, CupolaNode, cupolaMount, resolveCupolaPlacement } from './index' + +const roof = RoofSegmentNode.parse({ width: 8, depth: 6, roofType: 'gable' }) +const cupola = CupolaNode.parse({}) +test('ridge alignment seats the cupola at the ridge and footprint checks reject edges and trims', () => { + const ridge = resolveCupolaPlacement(roof, cupola, 0.13, 1, { alignRidge: true }) + expect(ridge.position[2]).toBeCloseTo(0) + expect(ridge.valid).toBe(true) + expect(resolveCupolaPlacement(roof, cupola, 4.5, 0).valid).toBe(false) + expect( + resolveCupolaPlacement(RoofSegmentNode.parse({ ...roof, trim: { right: 2 } }), cupola, 1.8, 0) + .valid, + ).toBe(false) +}) +test('grid, raw cursor and roof offset retain their requested coordinates', () => { + const grid = resolveCupolaPlacement(roof, cupola, 0.13, 1.37, { gridStep: 0.25, roofOffset: 0.2 }) + expect(grid.position[0]).toBe(0.25) + expect(grid.position[2]).toBe(1.25) + expect(grid.position[1]).toBeCloseTo( + getRoofOuterSurfaceFrameAtPoint(roof, 0.25, 1.25).point.y + 0.2, + ) + expect(resolveCupolaPlacement(roof, cupola, 0.13, 1.37).position[0]).toBe(0.13) +}) +test('the upright mounting skirt meets the roof around its full perimeter on ridge and slope', () => { + for (const position of [ + [0, 0, 0], + [1, 0, 1], + ] as [number, number, number][]) { + const node = { ...cupola, position, rotation: 0.4 } + const mount = cupolaMount(roof, node) + const geometry = buildCupolaGeometry(node, roof) + const vertices = geometry.getAttribute('position') + const c = Math.cos(node.rotation), + s = Math.sin(node.rotation) + for (const [x, y, z] of mount.perimeter) { + const expected = getRoofOuterSurfaceFrameAtPoint( + roof, + position[0] + x * c + z * s, + position[2] - x * s + z * c, + ).point.y + expect(y + mount.seatY).toBeCloseTo(expected, 6) + expect( + Array.from({ length: vertices.count }, (_, i) => + Math.hypot( + vertices.getX(i) - x, + vertices.getY(i) - (y + mount.seatY - mount.centerY), + vertices.getZ(i) - z, + ), + ).some((d) => d < 1e-6), + ).toBe(true) + } + geometry.dispose() + } +}) + +test('magnetic placement on a shed stays on the slope instead of jumping to its high eave', () => { + const shed = RoofSegmentNode.parse({ roofType: 'shed', width: 8, depth: 6 }) + const placed = resolveCupolaPlacement(shed, cupola, 0, 1, { alignRidge: true }) + expect(placed.position[2]).toBe(1) + expect(placed.valid).toBe(true) +}) diff --git a/packages/nodes/src/cupola/placement.ts b/packages/nodes/src/cupola/placement.ts new file mode 100644 index 0000000000..913d2bcb09 --- /dev/null +++ b/packages/nodes/src/cupola/placement.ts @@ -0,0 +1,97 @@ +import { + type CupolaNode, + getRoofSegmentVisibleTopBounds, + type Ring, + type RoofSegmentNode, +} from '@pascal-app/core' +import { getRoofOuterSurfaceFrameAtPoint } from '@pascal-app/viewer' +import { resolveRidgeSnap } from '../shared/ridge-snap' +import { roofFootprintInsideTrim } from '../shared/roof-footprint-trim' + +export function cupolaMount( + segment: RoofSegmentNode, + node: CupolaNode, + x = node.position[0], + z = node.position[2], +) { + const hw = Math.max(0.2, node.width) / 2 + const hd = Math.max(0.2, node.depth) / 2 + const extra = Math.min(hw, hd) * 0.12 + const corners = [ + [-hw - extra, -hd - extra], + [hw + extra, -hd - extra], + [hw + extra, hd + extra], + [-hw - extra, hd + extra], + ] + const c = Math.cos(node.rotation), + s = Math.sin(node.rotation) + const heightAt = (px: number, pz: number) => + getRoofOuterSurfaceFrameAtPoint(segment, x + px * c + pz * s, z - px * s + pz * c).point.y + const perimeter = corners.flatMap((a, i) => { + const b = corners[(i + 1) % 4]! + return Array.from({ length: 16 }, (_, j) => { + const px = a[0]! + ((b[0]! - a[0]!) * j) / 16 + const pz = a[1]! + ((b[1]! - a[1]!) * j) / 16 + return [px, heightAt(px, pz), pz] as [number, number, number] + }) + }) + const seatY = Math.max(heightAt(0, 0), ...perimeter.map((p) => p[1])) + return { + seatY, + centerY: heightAt(0, 0), + perimeter: perimeter.map(([px, y, pz]) => [px, y - seatY, pz] as [number, number, number]), + } +} + +export function resolveCupolaPlacement( + segment: RoofSegmentNode, + node: CupolaNode, + x: number, + z: number, + options: { gridStep?: number; roofOffset?: number; alignRidge?: boolean } = {}, +) { + let rotation = node.rotation + const step = options.gridStep ?? 0 + if (step > 0) { + x = Math.round(x / step) * step + z = Math.round(z / step) * step + } + if ( + options.alignRidge && + !['flat', 'shed', 'conical', 'barrel', 'glazed'].includes(segment.roofType) + ) { + const ridge = resolveRidgeSnap(segment, x, z) + if (ridge) { + x = ridge.localX + z = ridge.localZ + rotation = ridge.rotation + } + } + const hw = Math.max(0.2, node.width) / 2, + hd = Math.max(0.2, node.depth) / 2 + const extra = Math.min(hw, hd) * 0.22 + const c = Math.cos(rotation), + s = Math.sin(rotation) + const footprint: Ring = [ + [-hw - extra, -hd - extra], + [hw + extra, -hd - extra], + [hw + extra, hd + extra], + [-hw - extra, hd + extra], + ].map(([px, pz]) => [x + px! * c + pz! * s, z - px! * s + pz! * c]) + const bounds = getRoofSegmentVisibleTopBounds(segment) + const valid = + roofFootprintInsideTrim(segment, footprint) && + footprint.every( + ([px, pz]) => + px >= bounds.minX && px <= bounds.maxX && pz >= bounds.minZ && pz <= bounds.maxZ, + ) + return { + position: [ + x, + cupolaMount(segment, { ...node, rotation }, x, z).centerY + (options.roofOffset ?? 0), + z, + ] as [number, number, number], + rotation, + valid, + } +} diff --git a/packages/nodes/src/cupola/preview.tsx b/packages/nodes/src/cupola/preview.tsx index 579844241b..3a51c6cdfe 100644 --- a/packages/nodes/src/cupola/preview.tsx +++ b/packages/nodes/src/cupola/preview.tsx @@ -1,5 +1,7 @@ 'use client' +import { type AnyNodeId, type RoofSegmentNode, useScene } from '@pascal-app/core' + import { useEffect, useMemo } from 'react' import * as THREE from 'three' import { INVALID_GHOST_COLOR } from '../shared/ghost-materials' @@ -13,10 +15,25 @@ import type { CupolaNode } from './schema' * so the preview doesn't intercept the cursor ray feeding the tool. */ const CupolaPreview = ({ node, invalid }: { node: CupolaNode; invalid?: boolean }) => { + const segment = useScene((s) => + node.roofSegmentId + ? (s.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined) + : undefined, + ) // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. const geometry = useMemo( - () => buildCupolaGeometry(node), - [node.width, node.depth, node.height, node.roofStyle, node.finial], + () => buildCupolaGeometry(node, segment), + [ + node.width, + node.depth, + node.height, + node.roofStyle, + node.finial, + node.rotation, + node.position[0], + node.position[2], + segment, + ], ) const material = useMemo( @@ -37,14 +54,9 @@ const CupolaPreview = ({ node, invalid }: { node: CupolaNode; invalid?: boolean const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry]) - useEffect( - () => () => { - geometry.dispose() - edgesGeometry.dispose() - material.dispose() - }, - [geometry, edgesGeometry, material], - ) + useEffect(() => () => geometry.dispose(), [geometry]) + useEffect(() => () => edgesGeometry.dispose(), [edgesGeometry]) + useEffect(() => () => material.dispose(), [material]) return ( @@ -56,7 +68,12 @@ const CupolaPreview = ({ node, invalid }: { node: CupolaNode; invalid?: boolean }} /> - + ) diff --git a/packages/nodes/src/cupola/renderer.tsx b/packages/nodes/src/cupola/renderer.tsx index 40d67042b4..25dd8460f9 100644 --- a/packages/nodes/src/cupola/renderer.tsx +++ b/packages/nodes/src/cupola/renderer.tsx @@ -19,7 +19,6 @@ import { } from '@pascal-app/viewer' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' -import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip' import { buildCupolaGeometry } from './geometry' @@ -33,7 +32,7 @@ const defaultMaterial = new THREE.MeshStandardMaterial({ * Cupola renderer. Same transform stack as the box vent — the cupola is * parented to a roof-segment, so this reads the segment directly and * reproduces the segment-local transform (segment position → rotation → - * cupola position → slope tilt → cupola yaw → mesh). No animation. + * cupola position → cupola yaw → roof-fitting mesh). No animation. */ const CupolaRenderer = ({ node: storeNode }: { node: CupolaNode }) => { const ref = useRef(null!) @@ -58,17 +57,21 @@ const CupolaRenderer = ({ node: storeNode }: { node: CupolaNode }) => { // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. const geometry = useMemo( - () => buildCupolaGeometry(node), - [node.width, node.depth, node.height, node.roofStyle, node.finial], + () => buildCupolaGeometry(node, segment), + [ + node.width, + node.depth, + node.height, + node.roofStyle, + node.finial, + node.rotation, + node.position[0], + node.position[2], + segment, + ], ) useEffect(() => () => geometry.dispose(), [geometry]) - 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]]) - const material = useMemo(() => { const roleDefault = createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) const resolve = (role: 'base' | 'body' | 'roof' | 'louvers') => { @@ -96,8 +99,8 @@ const CupolaRenderer = ({ node: storeNode }: { node: CupolaNode }) => { const yAxis = useMemo(() => new THREE.Vector3(0, 1, 0), []) const composedQuat = useMemo(() => { const yawQuat = new THREE.Quaternion().setFromAxisAngle(yAxis, node.rotation ?? 0) - return new THREE.Quaternion().copy(surfaceQuat).multiply(yawQuat) - }, [surfaceQuat, node.rotation, yAxis]) + return yawQuat + }, [node.rotation, yAxis]) // Map cupola-local geometry into the host segment's local frame (where the // trim cut prisms live) — same pose the inner mesh group is mounted with. diff --git a/packages/nodes/src/cupola/tool.test.tsx b/packages/nodes/src/cupola/tool.test.tsx new file mode 100644 index 0000000000..0878043ea9 --- /dev/null +++ b/packages/nodes/src/cupola/tool.test.tsx @@ -0,0 +1,90 @@ +import { expect, test } from 'bun:test' +import { + BuildingNode, + emitter, + LevelNode, + nodeRegistry, + RoofNode, + RoofSegmentNode, + registerNode, + sceneRegistry, + useScene, +} from '@pascal-app/core' +import { useEditor, usePlacementPreview } from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { act, create } from '@react-three/test-renderer' +import * as THREE from 'three' +import { buildCupolaGeometry, cupolaDefinition } from './index' + +test('cupola placement matches its preview, honors grid mode and blocks unsupported drops', async () => { + const restoreRegistry = nodeRegistry._snapshot() + if (!nodeRegistry.get('cupola')) registerNode(cupolaDefinition) + const savedEditor = useEditor.getState() + useEditor.setState({ mode: 'build', tool: 'cupola', gridSnapStep: 0.25 }) + useEditor.getState().setSnappingMode('item', 'grid') + const savedScene = useScene.getState() + const savedSelection = useViewer.getState().selection + const building = BuildingNode.parse({}) + const level = LevelNode.parse({ parentId: building.id }) + const roof = RoofNode.parse({ parentId: level.id }) + const segment = RoofSegmentNode.parse({ parentId: roof.id, width: 20, depth: 20 }) + roof.children = [segment.id] + level.children = [roof.id] + building.children = [level.id] + useScene.setState({ + nodes: { [building.id]: building, [level.id]: level, [roof.id]: roof, [segment.id]: segment }, + rootNodeIds: [building.id], + dirtyNodes: new Set(), + readOnly: false, + }) + useViewer.getState().setSelection({ buildingId: building.id, levelId: level.id }) + sceneRegistry.nodes.set(segment.id, new THREE.Group()) + const { default: Tool } = await cupolaDefinition.tool!() + const renderer = await create() + try { + const event = { + node: roof, + position: [0.13, 3, 3.13] as [number, number, number], + stopPropagation: () => {}, + } + await act(async () => emitter.emit('roof:move', event)) + const preview = usePlacementPreview.getState().node + expect(preview?.type).toBe('cupola') + if (preview?.type !== 'cupola') throw new Error('Missing cupola preview') + await act(async () => emitter.emit('roof:click', event)) + const placed = Object.values(useScene.getState().nodes).find((node) => node.type === 'cupola') + expect(placed).toBeDefined() + if (placed?.type !== 'cupola') throw new Error('Missing cupola') + expect(preview.position).toEqual(placed.position) + expect(placed.position[0]).toBe(0.25) + expect(placed.position[2]).toBe(3.25) + expect(preview.id).not.toBe(placed.id) + const previewGeometry = buildCupolaGeometry(preview, segment)! + const placedGeometry = buildCupolaGeometry(placed, segment)! + previewGeometry.computeBoundingBox() + placedGeometry.computeBoundingBox() + expect(previewGeometry.boundingBox!.equals(placedGeometry.boundingBox!)).toBe(true) + previewGeometry.dispose() + placedGeometry.dispose() + expect(usePlacementPreview.getState().node).toBeNull() + const outside = { ...event, position: [30, 3, 3] as [number, number, number] } + await act(async () => emitter.emit('roof:click', outside)) + expect( + Object.values(useScene.getState().nodes).filter((n) => n.type === 'cupola'), + ).toHaveLength(1) + await act(async () => + emitter.emit('roof:click', { ...outside, nativeEvent: { altKey: true } as never }), + ) + expect( + Object.values(useScene.getState().nodes).filter((n) => n.type === 'cupola'), + ).toHaveLength(2) + } finally { + restoreRegistry() + useEditor.setState(savedEditor) + await renderer.unmount() + sceneRegistry.nodes.delete(segment.id) + useScene.setState(savedScene) + useViewer.getState().setSelection(savedSelection) + usePlacementPreview.getState().clear() + } +}) diff --git a/packages/nodes/src/cupola/tool.tsx b/packages/nodes/src/cupola/tool.tsx index fbf004deb2..928bef0e4d 100644 --- a/packages/nodes/src/cupola/tool.tsx +++ b/packages/nodes/src/cupola/tool.tsx @@ -1,149 +1,157 @@ 'use client' +import { type AnyNodeId, CupolaNode, emitter, type RoofEvent, useScene } from '@pascal-app/core' import { - type AnyNodeId, - CupolaNode, - emitter, - type RoofEvent, - type RoofNode, - sceneRegistry, - useScene, -} from '@pascal-app/core' -import { triggerSFX } from '@pascal-app/editor' + isGridSnapActive, + isMagneticSnapActive, + triggerSFX, + useEditor, + usePlacementPreview, +} from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import * as THREE from 'three' +import { roofSegmentLocalToBuildingLocal } from '../shared/relative-roof-drag' import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' -import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' import { clearRoofSurfacePlacementGuides, - publishRoofSurfacePlacementGuides, - roofSurfaceFootprintFromNode, + publishRoofSurfaceNodePlacementGuides, + snapRoofSurfaceNodeTarget, } from '../shared/roof-surface-placement-guides' import { cupolaDefinition } from './definition' +import { resolveCupolaPlacement } from './placement' import CupolaPreview from './preview' -const worldPoint = new THREE.Vector3() - -/** - * Cupola placement tool. Mounts when the palette activates the cupola kind; - * listens for `roof:*` events; on click commits a new `CupolaNode` parented - * to the targeted segment with segment-local coordinates. Mirrors box-vent. - */ -const CupolaTool = () => { +export default function CupolaTool() { 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(null) - const [previewYaw, setPreviewYaw] = useState(0) - const lastSnapRef = useRef<[number, number] | null>(null) - - const previewNode = useMemo( - () => - CupolaNode.parse({ - ...cupolaDefinition.defaults(), - name: 'Cupola', - position: [0, 0, 0], - rotation: 0, - }), + const seed = useMemo( + () => CupolaNode.parse({ ...cupolaDefinition.defaults(), name: 'Cupola' }), [], ) + const [preview, setPreview] = useState<{ + node: CupolaNode + position: [number, number, number] + normal: THREE.Quaternion + yaw: number + valid: boolean + } | null>(null) 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 clear = () => { + setPreview(null) + if (usePlacementPreview.getState().node?.id === seed.id) + usePlacementPreview.getState().clear() + clearRoofSurfacePlacementGuides() } - - 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 (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== sx || prev[1] !== sz)) { - triggerSFX('sfx:grid-snap') - lastSnapRef.current = [sx, sz] + const resolve = (event: RoofEvent) => { + const hit = resolveRoofSegmentHit(event.node, ...event.position) + if (!hit) return null + const force = event.nativeEvent?.altKey === true + let placement = resolveCupolaPlacement(hit.segment, seed, hit.localX, hit.localZ, { + gridStep: !force && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0, + alignRidge: !force && isMagneticSnapActive(), + }) + if (!force && isMagneticSnapActive()) { + const target = snapRoofSurfaceNodeTarget({ + target: { + ...hit, + hit, + localX: placement.position[0], + localY: placement.position[1], + localZ: placement.position[2], + }, + node: { ...seed, rotation: placement.rotation }, + }) + placement = resolveCupolaPlacement(hit.segment, seed, target.localX, target.localZ, { + alignRidge: !force && isMagneticSnapActive(), + }) } - - 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)) - publishRoofSurfacePlacementGuides({ - roof: event.node as RoofNode, + return { hit, placement, force } + } + const show = (event: RoofEvent) => { + const resolved = resolve(event) + if (!resolved) { + clear() + return + } + const { hit, placement, force } = resolved + const node = { + ...seed, + position: placement.position, + rotation: placement.rotation, + parentId: hit.segment.id, + roofSegmentId: hit.segment.id, + } + setPreview({ + node, + position: roofSegmentLocalToBuildingLocal(hit.segment.id, placement.position), + normal: new THREE.Quaternion(), + yaw: event.node.rotation + hit.segment.rotation, + valid: placement.valid || force, + }) + usePlacementPreview.getState().set(node, hit.segment) + publishRoofSurfaceNodePlacementGuides({ + roof: event.node, segment: hit.segment, - center: [hit.localX, hit.localY, hit.localZ], - footprint: roofSurfaceFootprintFromNode(previewNode), + center: placement.position, + node, }) 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 cupola = CupolaNode.parse({ - ...cupolaDefinition.defaults(), - name: 'Cupola', + const place = (event: RoofEvent) => { + const resolved = resolve(event) + if (!resolved) return + const { hit, placement, force } = resolved + if (!placement.valid && !force) { + show(event) + return + } + const node = CupolaNode.parse({ + ...seed, + id: undefined, roofSegmentId: hit.segment.id, - position: [hit.localX, hit.localY, hit.localZ], - rotation: 0, + position: placement.position, + rotation: placement.rotation, }) - state.createNode(cupola, hit.segment.id as AnyNodeId) - state.dirtyNodes.add(hit.segment.id as AnyNodeId) - setSelection({ selectedIds: [cupola.id] }) + useScene.getState().createNode(node, hit.segment.id as AnyNodeId) + useViewer.getState().setSelection({ selectedIds: [node.id] }) triggerSFX('sfx:item-place') - clearRoofSurfacePlacementGuides() + clear() event.stopPropagation() } - - emitter.on('roof:move', updatePreview) - emitter.on('roof:enter', updatePreview) - emitter.on('roof:click', onClick) - + emitter.on('roof:move', show) + emitter.on('roof:enter', show) + emitter.on('roof:leave', clear) + emitter.on('roof:click', place) + emitter.on('tool:cancel', clear) return () => { - emitter.off('roof:move', updatePreview) - emitter.off('roof:enter', updatePreview) - emitter.off('roof:click', onClick) - clearRoofSurfacePlacementGuides() + emitter.off('roof:move', show) + emitter.off('roof:enter', show) + emitter.off('roof:leave', clear) + emitter.off('roof:click', place) + emitter.off('tool:cancel', clear) + clear() } - }, [activeBuildingId, setSelection, previewNode]) + }, [activeBuildingId, seed]) return ( <> } + ghost={} onInvalidTarget={() => { - setPreviewPos(null) - setPreviewSurfaceQuat(null) + setPreview(null) + usePlacementPreview.getState().clear() clearRoofSurfacePlacementGuides() }} /> - {activeBuildingId && previewPos && previewSurfaceQuat && ( - - - - + {preview && ( + + + + @@ -151,5 +159,3 @@ const CupolaTool = () => { ) } - -export default CupolaTool diff --git a/packages/nodes/src/dormer/__tests__/geometry.test.ts b/packages/nodes/src/dormer/__tests__/geometry.test.ts index 7cf90fe486..dbff8a87d1 100644 --- a/packages/nodes/src/dormer/__tests__/geometry.test.ts +++ b/packages/nodes/src/dormer/__tests__/geometry.test.ts @@ -8,9 +8,12 @@ import { WindowNode, } from '@pascal-app/core' import { DoubleSide, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three' -import { buildDormerRoofCut, generateDormerGeometry } from '../csg-geometry' -import { buildDormerGhostGeometry } from '../geometry' -import { DormerNode } from '../schema' +import { + buildDormerGhostGeometry, + buildDormerRoofCut, + DormerNode, + generateDormerGeometry, +} from '../index' describe('buildDormerGhostGeometry (placement preview)', () => { test('returns a buffer geometry with position attribute', () => { @@ -289,3 +292,21 @@ describe('getDormerExposedFaces', () => { expect(getDormerDefaultWindowFace(dormerAt(seg, 0, 1.5), seg)).toBe('front') }) }) + +test('small Dutch dormer retains a continuous roof across its upper gablet and hip', () => { + const dormer = DormerNode.parse({ + roofType: 'dutch', + height: 0.8, + wallSkirtHeight: 1.5, + position: [0, 10, 0], + }) + const geometry = generateDormerGeometry(dormer, hostSegment()) + const material = new MeshBasicMaterial({ side: DoubleSide }) + const mesh = new Mesh(geometry, material) + for (const z of [0.3, 0.4, 0.5, 0.6, 0.7]) { + const hit = new Raycaster(new Vector3(0, 5, z), new Vector3(0, -1, 0)).intersectObject(mesh)[0] + expect(hit?.point.y).toBeGreaterThan(dormer.height + 0.1) + } + geometry.dispose() + material.dispose() +}) diff --git a/packages/nodes/src/dormer/csg-geometry.ts b/packages/nodes/src/dormer/csg-geometry.ts index 6aaf731328..a39dfbec06 100644 --- a/packages/nodes/src/dormer/csg-geometry.ts +++ b/packages/nodes/src/dormer/csg-geometry.ts @@ -3,7 +3,6 @@ import { dormerWallFacePointToDormer, getDormerWallFaceFrame, getPitchFromActiveRoofHeight, - ROOF_SHAPE_DEFAULTS, type RoofSegmentNode, type WindowNode, } from '@pascal-app/core' @@ -23,7 +22,7 @@ import { SUBTRACTION, } from '@pascal-app/viewer' import * as THREE from 'three' -import { buildDormerShellGeometry, getDormerBodyYaw } from './geometry' +import { buildDormerShellGeometry, DORMER_ROOF_SHAPE, getDormerBodyYaw } from './geometry' // Legacy default for the hung-wall (skirt) height. Used as a fallback // when `dormer.wallSkirtHeight` is undefined (e.g. old saved scenes). @@ -130,7 +129,7 @@ export function generateDormerGeometry( }), // Dormers don't expose multi-slope shape tuning; bake the schema // defaults so the virtualSegment renders the canonical kink positions. - ...ROOF_SHAPE_DEFAULTS, + ...DORMER_ROOF_SHAPE, wallThickness: 0.05, deckThickness: 0.04, overhang: 0.08, @@ -138,11 +137,13 @@ export function generateDormerGeometry( managedByParent: false, wallShell: 'auto', shedInsetEndPanels: false, - fascia: false, - fasciaHighEdge: false, + fascia: true, + fasciaHighEdge: true, + fasciaHeight: 0.1, + fasciaDepth: 0.018, } - const dormerBrushes = getRoofSegmentBrushes(virtualSegment) + const dormerBrushes = getRoofSegmentBrushes(virtualSegment, { fascia: true }) if (!dormerBrushes) { console.warn('[dormer] getRoofSegmentBrushes returned null; using fallback silhouette.') return buildDormerFallbackGeometry(dormer) diff --git a/packages/nodes/src/dormer/definition.ts b/packages/nodes/src/dormer/definition.ts index e2e1711970..6ba2d5f2fd 100644 --- a/packages/nodes/src/dormer/definition.ts +++ b/packages/nodes/src/dormer/definition.ts @@ -268,12 +268,20 @@ export const dormerDefinition: NodeDefinition = { schema: DormerNode, category: 'structure', surfaceRole: 'roof', + snapProfile: 'item', 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 stub = DormerNodeSchema.parse({ + height: 0.8, + wallSkirtHeight: 1.5, + windowOffsetY: 1.15, + windowColumns: 2, + windowRows: 2, + windowSill: true, + }) const { id: _id, type: _type, ...rest } = stub return rest }, @@ -328,6 +336,7 @@ export const dormerDefinition: NodeDefinition = { toolHints: [ { key: 'Left click', label: 'Place dormer on roof' }, { key: 'R / Shift+R', label: 'Rotate ghost ±15°' }, + { key: 'Alt', label: 'Place freely' }, { key: 'Esc', label: 'Cancel' }, ], diff --git a/packages/nodes/src/dormer/geometry.ts b/packages/nodes/src/dormer/geometry.ts index 40ca77bb05..e17aad9db2 100644 --- a/packages/nodes/src/dormer/geometry.ts +++ b/packages/nodes/src/dormer/geometry.ts @@ -8,11 +8,13 @@ import { 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. + * Dormer-sized rake details keep the Dutch gablet clear of its lower hip. */ -export const DORMER_PLACEMENT_SNAP_M = 0.05 +export const DORMER_ROOF_SHAPE = { + ...ROOF_SHAPE_DEFAULTS, + dutchGabletRake: 0.06, + dutchTopRakeThickness: 0.04, +} /** * Rotation step (radians) used by the keyboard rotate shortcuts (R / @@ -57,7 +59,7 @@ export function buildDormerShellGeometry(node: DormerNode): THREE.BufferGeometry baseW: segW, baseD: segD, tanTheta: Math.tan((pitch * Math.PI) / 180), - shapeRatios: getRoofShapeRatios(ROOF_SHAPE_DEFAULTS), + shapeRatios: getRoofShapeRatios(DORMER_ROOF_SHAPE), }) const positions: number[] = [] const materialGroups: Array<{ start: number; count: number; materialIndex: number }> = [] diff --git a/packages/nodes/src/dormer/index.ts b/packages/nodes/src/dormer/index.ts index 71b2eea625..cb33f2bd40 100644 --- a/packages/nodes/src/dormer/index.ts +++ b/packages/nodes/src/dormer/index.ts @@ -1,5 +1,7 @@ +export { buildDormerRoofCut, generateDormerGeometry } from './csg-geometry' export { dormerDefinition } from './definition' export { buildDormerGhostGeometry } from './geometry' +export { resolveDormerPlacement } from './placement' export type { DormerSurfaceMaterialRole, DormerSurfaceMaterialSpec, diff --git a/packages/nodes/src/dormer/move-tool.tsx b/packages/nodes/src/dormer/move-tool.tsx index ddab264e97..d456eee433 100644 --- a/packages/nodes/src/dormer/move-tool.tsx +++ b/packages/nodes/src/dormer/move-tool.tsx @@ -4,13 +4,13 @@ import { type AnyNodeId, type DormerNode, DormerNode as DormerNodeSchema, - type RoofSegmentNode, + emitter, sceneRegistry, useScene, } from '@pascal-app/core' -import { commitFreshPlacementSubtree, useEditor } from '@pascal-app/editor' +import { commitFreshPlacementSubtree, markToolCancelConsumed, useEditor } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { useEffect, useMemo } from 'react' +import { useEffect, useMemo, useRef } from 'react' import { DormerPlacementGuides } from './placement-guides' import DormerPreview from './preview' import { useDormerPlacement } from './use-dormer-placement' @@ -42,46 +42,42 @@ const MoveDormerTool = ({ node }: { node: DormerNode }) => { }, [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. + // commit without recording temporary metadata in scene history. const meta = typeof node.metadata === 'object' && node.metadata !== null ? (node.metadata as Record) : {} const isNew = !!meta.isNew const originalRotation = node.rotation ?? 0 - const originalMetadata = node.metadata + const committed = useRef(false) - // biome-ignore lint/correctness/useExhaustiveDependencies: capture-on-mount; meta is intentionally not re-read on changes. useEffect(() => { - if (!isNew) { - useScene.getState().updateNode(node.id as AnyNodeId, { - metadata: { ...meta, isTransient: true }, - }) + const cancel = () => { + markToolCancelConsumed() + setMovingNode(null) } + emitter.on('tool:cancel', cancel) 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. + emitter.off('tool:cancel', cancel) + // Restore visibility when the tool exits. const obj = sceneRegistry.nodes.get(node.id) if (obj) obj.visible = prevVisible ?? true - if (isNew) { + if (isNew && !committed.current) { if (node.id && useScene.getState().nodes[node.id]) { useScene.getState().deleteNode(node.id as AnyNodeId) } useScene.temporal.getState().resume() - } else { - useScene.getState().updateNode(node.id as AnyNodeId, { - metadata: originalMetadata, - }) } } - }, [node.id, isNew]) + }, [node.id, isNew, setMovingNode]) - const { activeBuildingId, segmentXform, hitSegment, hitLocal, ghostRotation } = + const { activeBuildingId, segmentXform, hitSegment, hitLocal, ghostRotation, valid } = useDormerPlacement({ + node, initialRotation: originalRotation, relativeStart: { position: [...node.position] as [number, number, number], @@ -89,6 +85,7 @@ const MoveDormerTool = ({ node }: { node: DormerNode }) => { }, onCommit: (hit, rotation) => { const state = useScene.getState() + committed.current = true // Strip the `isNew` / `isTransient` flags — only used to mark a // clone or in-flight move that hasn't been committed yet. @@ -152,21 +149,6 @@ const MoveDormerTool = ({ node }: { node: DormerNode }) => { }) 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) @@ -191,7 +173,7 @@ const MoveDormerTool = ({ node }: { node: DormerNode }) => { )} - + diff --git a/packages/nodes/src/dormer/panel-position-section.tsx b/packages/nodes/src/dormer/panel-position-section.tsx index a2f95ee440..43b06d61fa 100644 --- a/packages/nodes/src/dormer/panel-position-section.tsx +++ b/packages/nodes/src/dormer/panel-position-section.tsx @@ -9,6 +9,7 @@ import { useScene, } from '@pascal-app/core' import { PanelSection, SliderControl } from '@pascal-app/editor' +import { getRoofOuterSurfaceFrameAtPoint } from '@pascal-app/viewer' import { useMemo } from 'react' import { Vector3 } from 'three' @@ -49,12 +50,12 @@ export function DormerPositionSection({ let worldRotation = nodeRotation ?? 0 if (dormerObj) { dormerObj.updateWorldMatrix(true, false) - const localPt = new Vector3(px ?? 0, 0, pz ?? 0) + const localPt = new Vector3() 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) + worldRotation = Math.atan2(-(m[2] ?? 0), m[0] ?? 1) } let bounds: { minX: number; maxX: number; minZ: number; maxZ: number } | null = null @@ -97,7 +98,7 @@ export function DormerPositionSection({ const state = useScene.getState() const worldPt = new Vector3(wx, 0, wz) for (const candidate of Object.values(state.nodes)) { - if (candidate?.type !== 'roof-segment') continue + if (candidate?.type !== 'roof-segment' || candidate.parentId !== segment?.parentId) continue const seg = candidate as RoofSegmentNode const segObj = sceneRegistry.nodes.get(seg.id) if (!segObj) continue @@ -122,6 +123,8 @@ export function DormerPositionSection({ return { localX: local.x, localZ: local.z } } + const surfaceOffset = segment ? py - getRoofOuterSurfaceFrameAtPoint(segment, px, pz).point.y : 0 + const commitWorldPosition = (newWorldX: number, newWorldZ: number) => { if (!segment) return const oldWorldRotation = worldRotation_now @@ -141,14 +144,26 @@ export function DormerPositionSection({ commitProp({ roofSegmentId: target.segment.id, parentId: target.segment.id, - position: [target.localX, 0, target.localZ], + position: [ + target.localX, + getRoofOuterSurfaceFrameAtPoint(target.segment, target.localX, target.localZ).point.y + + surfaceOffset, + target.localZ, + ], rotation: newSegLocalRot, } as Partial) } 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] }) + commitProp({ + position: [ + local.localX, + getRoofOuterSurfaceFrameAtPoint(segment, local.localX, local.localZ).point.y + + surfaceOffset, + local.localZ, + ], + }) } } @@ -165,7 +180,7 @@ export function DormerPositionSection({ } return ( - + { if (!segment) return const local = worldToSegLocal(newWorldX, worldZ_now, segment) - previewProp({ position: [local.localX, py ?? 0, local.localZ] }) + previewProp({ + position: [ + local.localX, + getRoofOuterSurfaceFrameAtPoint(segment, local.localX, local.localZ).point.y + + surfaceOffset, + local.localZ, + ], + }) }} onCommit={(newWorldX) => commitWorldPosition(newWorldX, worldZ_now)} precision={2} @@ -189,7 +211,14 @@ export function DormerPositionSection({ onChange={(newWorldZ) => { if (!segment) return const local = worldToSegLocal(worldX_now, newWorldZ, segment) - previewProp({ position: [local.localX, py ?? 0, local.localZ] }) + previewProp({ + position: [ + local.localX, + getRoofOuterSurfaceFrameAtPoint(segment, local.localX, local.localZ).point.y + + surfaceOffset, + local.localZ, + ], + }) }} onCommit={(newWorldZ) => commitWorldPosition(worldX_now, newWorldZ)} precision={2} diff --git a/packages/nodes/src/dormer/panel.tsx b/packages/nodes/src/dormer/panel.tsx index e770815f33..2471e14408 100644 --- a/packages/nodes/src/dormer/panel.tsx +++ b/packages/nodes/src/dormer/panel.tsx @@ -136,15 +136,6 @@ export default function DormerPanel() { 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) @@ -241,23 +232,14 @@ export default function DormerPanel() { return ( - - -
+
{SECTION_OPTIONS.map((option) => { const isSelected = section === option.value return ( @@ -268,6 +250,7 @@ export default function DormerPanel() { ? 'border-orange-400/60 bg-orange-400/10 text-foreground' : 'border-border/50 bg-black/4 dark:bg-[#2C2C2E] text-muted-foreground hover:bg-black/8 dark:hover:bg-[#3e3e3e] hover:text-foreground', )} + aria-pressed={isSelected} key={option.value} onClick={() => setSection(option.value)} type="button" @@ -281,10 +264,38 @@ export default function DormerPanel() { {section === 'dormer' && ( <> + +
+ {ROOF_TYPE_OPTIONS.map((option) => { + const isSelected = node.roofType === option.value + return ( + + ) + })} +
+
+ previewProp({ width: v })} onCommit={(v) => commitProp({ width: v })} @@ -296,7 +307,6 @@ export default function DormerPanel() { /> previewProp({ depth: v })} onCommit={(v) => commitProp({ depth: v })} @@ -308,7 +318,6 @@ export default function DormerPanel() { /> previewProp({ height: v })} onCommit={(v) => commitProp({ height: v })} @@ -320,7 +329,6 @@ export default function DormerPanel() { /> previewProp({ roofHeight: v })} onCommit={(v) => commitProp({ roofHeight: v })} @@ -332,29 +340,6 @@ export default function DormerPanel() { /> - -
- {ROOF_TYPE_OPTIONS.map((option) => { - const isSelected = node.roofType === option.value - return ( - - ) - })} -
-
- {node.roofType === 'shed' && (
@@ -392,6 +377,15 @@ export default function DormerPanel() { /> )} + + { + const placed = resolveDormerPlacement(segment, dormer, 0.13, 1.37, { + gridStep: 0.25, + surfaceOffset: 0.2, + }) + expect(placed.position[0]).toBe(0.25) + expect(placed.position[2]).toBe(1.25) + expect(placed.position[1]).toBeCloseTo( + getRoofOuterSurfaceFrameAtPoint(segment, 0.25, 1.25).point.y + 0.2, + ) + expect(placed.valid).toBe(true) + expect(resolveDormerPlacement(segment, dormer, 0.13, 1.37).position[0]).toBe(0.13) +}) + +test('the full dormer eave footprint fits on one face and inside trims', () => { + expect(resolveDormerPlacement(segment, dormer, 0, 0).valid).toBe(false) + expect(resolveDormerPlacement(segment, dormer, 0, 3).valid).toBe(false) + const trimmed = RoofSegmentNode.parse({ ...segment, trim: { right: 2 } }) + expect(resolveDormerPlacement(trimmed, dormer, 1.8, 1.5).valid).toBe(false) + expect(resolveDormerPlacement(trimmed, dormer, 1, 1.5).valid).toBe(true) + expect( + resolveDormerPlacement(segment, { ...dormer, width: 7, rotation: Math.PI / 2 }, 0, 1.5).valid, + ).toBe(false) +}) + +test('new dormers expose a complete window above the roof datum without changing archive defaults', () => { + const window = createDormerDefaultWindow(dormer, 'window_default') + expect(window.position[1] - window.height / 2).toBeGreaterThanOrEqual(0) + expect(window.position[1] + window.height / 2).toBeLessThanOrEqual(dormer.height) + expect(window.sill).toBe(true) + expect(DormerNode.parse({}).height).toBe(0) +}) diff --git a/packages/nodes/src/dormer/placement.ts b/packages/nodes/src/dormer/placement.ts new file mode 100644 index 0000000000..cd398356c5 --- /dev/null +++ b/packages/nodes/src/dormer/placement.ts @@ -0,0 +1,40 @@ +import { type DormerNode, difference, type Ring, type RoofSegmentNode } from '@pascal-app/core' +import { getRoofOuterSurfaceFrameAtPoint } from '@pascal-app/viewer' +import { roofFootprintInsideTrim } from '../shared/roof-footprint-trim' +import { getRoofSurfaceFaceBoundsAt } from '../shared/roof-surface' + +export function resolveDormerPlacement( + segment: RoofSegmentNode, + node: DormerNode, + x: number, + z: number, + options: { gridStep?: number; surfaceOffset?: number } = {}, +) { + const step = options.gridStep ?? 0 + if (step > 0) { + x = Math.round(x / step) * step + z = Math.round(z / step) * step + } + const halfW = Math.max(0.05, node.width) / 2 + 0.12 + const halfD = Math.max(0.05, node.depth) / 2 + 0.12 + const c = Math.cos(node.rotation), + s = Math.sin(node.rotation) + const footprint: Ring = [ + [-halfW, -halfD], + [halfW, -halfD], + [halfW, halfD], + [-halfW, halfD], + ].map(([px, pz]) => [x + px! * c + pz! * s, z - px! * s + pz! * c]) + return { + position: [ + x, + getRoofOuterSurfaceFrameAtPoint(segment, x, z).point.y + (options.surfaceOffset ?? 0), + z, + ] as [number, number, number], + valid: + roofFootprintInsideTrim(segment, footprint) && + difference(footprint, getRoofSurfaceFaceBoundsAt(segment, x, z).polygon, { + throwOnError: true, + }).length === 0, + } +} diff --git a/packages/nodes/src/dormer/tool.test.tsx b/packages/nodes/src/dormer/tool.test.tsx new file mode 100644 index 0000000000..14cd3e7068 --- /dev/null +++ b/packages/nodes/src/dormer/tool.test.tsx @@ -0,0 +1,95 @@ +import { expect, test } from 'bun:test' +import { + BuildingNode, + emitter, + LevelNode, + nodeRegistry, + RoofNode, + RoofSegmentNode, + registerNode, + sceneRegistry, + useScene, +} from '@pascal-app/core' +import { getMovingNode, useEditor, usePlacementPreview } from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { act, create } from '@react-three/test-renderer' +import * as THREE from 'three' +import { dormerDefinition } from './index' + +test('dormer placement commits a grid-snapped dormer with its hosted window', async () => { + const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window') + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: Object.assign(new EventTarget(), { + requestAnimationFrame: () => 0, + cancelAnimationFrame: () => {}, + }), + }) + const restoreRegistry = nodeRegistry._snapshot() + if (!nodeRegistry.get('dormer')) registerNode(dormerDefinition) + const savedEditor = useEditor.getState() + useEditor.setState({ mode: 'build', tool: 'dormer', gridSnapStep: 0.25 }) + useEditor.getState().setSnappingMode('item', 'grid') + const savedScene = useScene.getState() + const savedSelection = useViewer.getState().selection + const building = BuildingNode.parse({}) + const level = LevelNode.parse({ parentId: building.id }) + const roof = RoofNode.parse({ parentId: level.id }) + const segment = RoofSegmentNode.parse({ parentId: roof.id, width: 20, depth: 20 }) + roof.children = [segment.id] + level.children = [roof.id] + building.children = [level.id] + useScene.setState({ + nodes: { [building.id]: building, [level.id]: level, [roof.id]: roof, [segment.id]: segment }, + rootNodeIds: [building.id], + dirtyNodes: new Set(), + readOnly: false, + }) + useViewer.getState().setSelection({ buildingId: building.id, levelId: level.id }) + sceneRegistry.nodes.set(segment.id, new THREE.Group()) + const { default: Tool } = await dormerDefinition.tool!() + let renderer = await create() + try { + const event = { + node: roof, + position: [0.13, 3, 3.13] as [number, number, number], + stopPropagation: () => {}, + } + await act(async () => emitter.emit('roof:move', event)) + await act(async () => emitter.emit('roof:click', event)) + const placed = Object.values(useScene.getState().nodes).find((node) => node.type === 'dormer') + expect(placed).toBeDefined() + if (placed?.type !== 'dormer') throw new Error('Missing dormer') + expect(placed.position[0]).toBe(0.25) + expect(placed.position[2]).toBe(3.25) + const windows = Object.values(useScene.getState().nodes).filter((n) => n.type === 'window') + expect(windows).toHaveLength(1) + expect(windows[0]!.parentId).toBe(placed.id) + expect(placed.children).toEqual([windows[0]!.id]) + expect(usePlacementPreview.getState().node).toBeNull() + await renderer.unmount() + const object = new THREE.Group() + sceneRegistry.nodes.set(placed.id, object) + useEditor.getState().setMovingNode(placed) + const { default: Move } = await dormerDefinition.affordanceTools!.move!() + renderer = await create() + expect(object.visible).toBe(false) + await act(async () => emitter.emit('tool:cancel')) + expect(getMovingNode()).toBeNull() + await renderer.unmount() + expect(object.visible).toBe(true) + expect(useScene.getState().nodes[windows[0]!.id]?.parentId).toBe(placed.id) + sceneRegistry.nodes.delete(placed.id) + } finally { + restoreRegistry() + useEditor.setState(savedEditor) + await renderer.unmount() + if (originalWindow) Object.defineProperty(globalThis, 'window', originalWindow) + else Reflect.deleteProperty(globalThis, 'window') + sceneRegistry.nodes.delete(segment.id) + sceneRegistry.nodes.delete(building.id) + useScene.setState(savedScene) + useViewer.getState().setSelection(savedSelection) + usePlacementPreview.getState().clear() + } +}) diff --git a/packages/nodes/src/dormer/tool.tsx b/packages/nodes/src/dormer/tool.tsx index 00bce365eb..0348d7291b 100644 --- a/packages/nodes/src/dormer/tool.tsx +++ b/packages/nodes/src/dormer/tool.tsx @@ -56,33 +56,45 @@ const DormerTool = () => { [], ) - const { activeBuildingId, clearPreview, segmentXform, hitSegment, 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) - const defaultWindow = createDormerDefaultWindow( - dormer, - `window_${dormer.id.replace(/^dormer_/, '')}_default`, - getDormerDefaultWindowFace(dormer, hit.segment), - ) - state.createNode(defaultWindow, dormer.id as AnyNodeId) - state.dirtyNodes.add(hit.segment.id as AnyNodeId) - setSelection({ selectedIds: [dormer.id] }) - usePlacementPreview.getState().clear() - }, - }) + const { + activeBuildingId, + clearPreview, + segmentXform, + hitSegment, + hitLocal, + ghostRotation, + valid, + } = useDormerPlacement({ + node: previewNode, + 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, + }) + const defaultWindow = createDormerDefaultWindow( + dormer, + `window_${dormer.id.replace(/^dormer_/, '')}_default`, + getDormerDefaultWindowFace(dormer, hit.segment), + ) + state.applyNodeChanges({ + create: [ + { node: dormer, parentId: hit.segment.id as AnyNodeId }, + { node: defaultWindow, parentId: dormer.id as AnyNodeId }, + ], + }) + state.dirtyNodes.add(hit.segment.id as AnyNodeId) + setSelection({ selectedIds: [dormer.id] }) + usePlacementPreview.getState().clear() + }, + }) useEffect(() => { const placementPreview = usePlacementPreview.getState() @@ -130,7 +142,7 @@ const DormerTool = () => { )} - + diff --git a/packages/nodes/src/dormer/use-dormer-placement.ts b/packages/nodes/src/dormer/use-dormer-placement.ts index 52d0e03e4a..4ce174930b 100644 --- a/packages/nodes/src/dormer/use-dormer-placement.ts +++ b/packages/nodes/src/dormer/use-dormer-placement.ts @@ -1,18 +1,32 @@ import { type AnyNodeId, + type DormerNode, emitter, type RoofEvent, type RoofNode, type RoofSegmentNode, sceneRegistry, + useScene, } from '@pascal-app/core' -import { consumePlacementDragRelease, triggerSFX } from '@pascal-app/editor' -import { useViewer } from '@pascal-app/viewer' -import { useEffect, useRef, useState } from 'react' +import { + consumePlacementDragRelease, + isGridSnapActive, + isMagneticSnapActive, + triggerSFX, + useEditor, +} from '@pascal-app/editor' +import { getRoofOuterSurfaceFrameAtPoint, useViewer } from '@pascal-app/viewer' +import { useCallback, useEffect, useRef, useState } from 'react' import * as THREE from 'three' import { createRelativeRoofDrag } from '../shared/relative-roof-drag' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' -import { DORMER_PLACEMENT_ROTATION_STEP, DORMER_PLACEMENT_SNAP_M } from './geometry' +import { getDownSlopeYaw } from '../shared/roof-surface' +import { + clearRoofSurfacePlacementGuides, + snapRoofSurfaceNodeTarget, +} from '../shared/roof-surface-placement-guides' +import { DORMER_PLACEMENT_ROTATION_STEP } from './geometry' +import { resolveDormerPlacement } from './placement' const tmpMatrix = new THREE.Matrix4() const tmpInv = new THREE.Matrix4() @@ -38,7 +52,7 @@ export type DormerPlacementHit = { * - 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 + * - visible snap mode, footprint validation and SFX cue * - keyboard rotate (R / Shift+R, ±15°) * * Does NOT own: @@ -50,6 +64,7 @@ export type DormerPlacementHit = { * the ghost, plus the live ghost rotation (driven by R / Shift+R). */ export function useDormerPlacement(opts: { + node: DormerNode initialRotation?: number relativeStart?: { position: [number, number, number] @@ -63,6 +78,7 @@ export function useDormerPlacement(opts: { hitSegment: RoofSegmentNode | null hitLocal: [number, number, number] | null ghostRotation: number + valid: boolean } { const activeBuildingId = useViewer((s) => s.selection.buildingId) @@ -70,6 +86,9 @@ export function useDormerPlacement(opts: { const [hitSegment, setHitSegment] = useState(null) const [hitLocal, setHitLocal] = useState<[number, number, number] | null>(null) const [ghostRotation, setGhostRotation] = useState(opts.initialRotation ?? 0) + const [valid, setValid] = useState(true) + const nodeRef = useRef(opts.node) + nodeRef.current = opts.node 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. @@ -81,11 +100,12 @@ export function useDormerPlacement(opts: { const onCommitRef = useRef(opts.onCommit) onCommitRef.current = opts.onCommit - const clearPreview = () => { + const clearPreview = useCallback(() => { setSegmentXform(null) setHitSegment(null) setHitLocal(null) - } + clearRoofSurfacePlacementGuides() + }, []) useEffect(() => { if (!activeBuildingId) return @@ -109,7 +129,6 @@ export function useDormerPlacement(opts: { ? createRelativeRoofDrag(relativeStartRef.current) : null let committed = false - let lastRelativeHit: DormerPlacementHit | null = null const resolvePlacementHit = (event: RoofEvent): DormerPlacementHit | null => { if (roofDrag) return roofDrag.resolve(event) @@ -121,54 +140,108 @@ export function useDormerPlacement(opts: { ) } - const updatePreview = (event: RoofEvent) => { - const wx = event.position[0] - 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 (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== sx || prev[1] !== sz)) { - triggerSFX('sfx:grid-snap') - lastSnapRef.current = [sx, sz] - } + let lastEvent: RoofEvent | null = null + let resolvedRotation = ghostRotationRef.current + let canPlace = false + const originalSegment = relativeStartRef.current?.roofSegmentId + ? (useScene.getState().nodes[relativeStartRef.current.roofSegmentId as AnyNodeId] as + | RoofSegmentNode + | undefined) + : undefined + const surfaceOffset = + originalSegment && relativeStartRef.current + ? relativeStartRef.current.position[1] - + getRoofOuterSurfaceFrameAtPoint( + originalSegment, + relativeStartRef.current.position[0], + relativeStartRef.current.position[2], + ).point.y + : 0 + const resolve = (event: RoofEvent, force = event.nativeEvent?.altKey === true) => { const hit = resolvePlacementHit(event) - if (!hit) return - if (roofDrag) lastRelativeHit = hit + if (!hit) return null + resolvedRotation = + ghostRotationRef.current + + (roofDrag ? 0 : getDownSlopeYaw(hit.localX, hit.localZ, hit.segment)) + const placedNode = { ...nodeRef.current, rotation: resolvedRotation } + let placement = resolveDormerPlacement(hit.segment, placedNode, hit.localX, hit.localZ, { + gridStep: !force && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0, + surfaceOffset, + }) + if (!force && isMagneticSnapActive()) { + const snapped = snapRoofSurfaceNodeTarget({ + target: { + ...hit, + hit: 'hit' in hit ? (hit.hit as ReturnType & {}) : hit, + localX: placement.position[0], + localY: placement.position[1], + localZ: placement.position[2], + }, + node: placedNode, + }) + placement = resolveDormerPlacement( + hit.segment, + placedNode, + snapped.localX, + snapped.localZ, + { surfaceOffset }, + ) + } + canPlace = force || placement.valid + setValid(canPlace) + setGhostRotation(resolvedRotation) + return { + ...hit, + localX: placement.position[0], + localY: placement.position[1], + localZ: placement.position[2], + } + } + const leave = () => { + lastEvent = null + clearPreview() + } + const updatePreview = (event: RoofEvent) => { + lastEvent = event + const hit = resolve(event) + if (!hit) { + leave() + return + } const xform = computeSegmentXform(hit.segment.id) - if (!xform) return + if (!xform) { + leave() + return + } setSegmentXform(xform) setHitSegment(hit.segment) - // 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]) + const prev = lastSnapRef.current + if ( + !event.nativeEvent?.altKey && + (isGridSnapActive() || isMagneticSnapActive()) && + (!prev || prev[0] !== hit.localX || prev[1] !== hit.localZ) + ) { + triggerSFX('sfx:grid-snap') + lastSnapRef.current = [hit.localX, hit.localZ] + } event.stopPropagation() } - - const onClick = (event: RoofEvent) => { - if (committed) return - const hit = roofDrag - ? (lastRelativeHit ?? resolvePlacementHit(event)) - : resolvePlacementHit(event) - if (!hit) return + const commit = (hit: DormerPlacementHit | null) => { + if (!hit || !canPlace || committed) return committed = true - onCommitRef.current(hit, ghostRotationRef.current) + onCommitRef.current(hit, resolvedRotation) triggerSFX('sfx:item-place') + leave() + } + const onClick = (event: RoofEvent) => { + commit(resolve(event)) event.stopPropagation() } - const onPlacementDragPointerUp = (event: PointerEvent) => { - if (committed) return if (!consumePlacementDragRelease(event)) return - const hit = roofDrag ? lastRelativeHit : null - if (!hit) return - committed = true - onCommitRef.current(hit, ghostRotationRef.current) - triggerSFX('sfx:item-place') + if (lastEvent) commit(resolve(lastEvent, event.altKey)) } const onKeyDown = (e: KeyboardEvent) => { @@ -181,13 +254,16 @@ export function useDormerPlacement(opts: { return const dir = e.shiftKey ? -1 : 1 ghostRotationRef.current += dir * DORMER_PLACEMENT_ROTATION_STEP - setGhostRotation(ghostRotationRef.current) + if (lastEvent) updatePreview(lastEvent) + else setGhostRotation(ghostRotationRef.current) e.preventDefault() } emitter.on('roof:move', updatePreview) emitter.on('roof:enter', updatePreview) emitter.on('roof:click', onClick) + emitter.on('roof:leave', leave) + emitter.on('tool:cancel', leave) window.addEventListener('keydown', onKeyDown) window.addEventListener('pointerup', onPlacementDragPointerUp) @@ -195,10 +271,13 @@ export function useDormerPlacement(opts: { emitter.off('roof:move', updatePreview) emitter.off('roof:enter', updatePreview) emitter.off('roof:click', onClick) + emitter.off('roof:leave', leave) + emitter.off('tool:cancel', leave) + clearRoofSurfacePlacementGuides() window.removeEventListener('keydown', onKeyDown) window.removeEventListener('pointerup', onPlacementDragPointerUp) } - }, [activeBuildingId]) + }, [activeBuildingId, clearPreview]) return { activeBuildingId: activeBuildingId ?? undefined, @@ -207,5 +286,6 @@ export function useDormerPlacement(opts: { hitSegment, hitLocal, ghostRotation, + valid, } } diff --git a/packages/nodes/src/downspout/geometry.ts b/packages/nodes/src/downspout/geometry.ts index f4a85e057b..f25d6a2661 100644 --- a/packages/nodes/src/downspout/geometry.ts +++ b/packages/nodes/src/downspout/geometry.ts @@ -35,7 +35,7 @@ import { * * Pure: no React, no scene access. */ -const RADIAL_SEGMENTS = 16 +const RADIAL_SEGMENTS = 24 const JOINT_SEGMENTS = 12 const FWD = new THREE.Vector3(0, 0, 1) const UP = new THREE.Vector3(0, 1, 0) @@ -223,7 +223,7 @@ function buildStraps( if (runLen < STRAP_END_MARGIN * 2 + 0.05) return [] const usable = runLen - STRAP_END_MARGIN * 2 - const count = Math.max(1, Math.floor(usable / Math.max(0.2, spacing)) + 1) + const count = Math.max(1, Math.ceil(usable / Math.max(0.2, spacing)) + 1) const stride = count > 1 ? usable / (count - 1) : 0 const w = 2 * dims.halfX + 2 * STRAP_OVERHANG const d = 2 * dims.halfZ + 2 * STRAP_OVERHANG @@ -231,7 +231,10 @@ function buildStraps( const straps: THREE.BufferGeometry[] = [] for (let i = 0; i < count; i++) { const y = count > 1 ? top - STRAP_END_MARGIN - i * stride : (top + bottom) / 2 - const band = new THREE.BoxGeometry(w, STRAP_THICKNESS, d).toNonIndexed() + const band = + dims.shape === 'round' + ? new THREE.CylinderGeometry(w / 2, w / 2, STRAP_THICKNESS, RADIAL_SEGMENTS).toNonIndexed() + : new THREE.BoxGeometry(w, STRAP_THICKNESS, d).toNonIndexed() applyPlanarWorldUvs(band) band.translate(0, y, z) straps.push(band) @@ -245,8 +248,16 @@ function buildStraps( */ function buildSplash(path: DownspoutPath): THREE.BufferGeometry | null { const [bx, by, bz] = path.bottom - const slab = new THREE.BoxGeometry(SPLASH_WIDTH, SPLASH_THICKNESS, SPLASH_LENGTH).toNonIndexed() - applyPlanarWorldUvs(slab) + const base = new THREE.BoxGeometry(SPLASH_WIDTH, SPLASH_THICKNESS, SPLASH_LENGTH).toNonIndexed() + const pieces = [base] + for (const sign of [-1, 1]) { + const rail = new THREE.BoxGeometry(0.018, 0.025, SPLASH_LENGTH).toNonIndexed() + rail.translate(sign * (SPLASH_WIDTH / 2 - 0.009), SPLASH_THICKNESS / 2 + 0.0125, 0) + pieces.push(rail) + } + for (const piece of pieces) applyPlanarWorldUvs(piece) + const slab = mergeGeometries(pieces, false)! + for (const piece of pieces) piece.dispose() // Tilt the far (+Z) end down so it slopes away from the wall. slab.rotateX(SPLASH_TILT) slab.translate(bx, by - SPLASH_THICKNESS / 2, bz + SPLASH_LENGTH / 2) diff --git a/packages/nodes/src/downspout/index.ts b/packages/nodes/src/downspout/index.ts index ffe441894a..526db0cb48 100644 --- a/packages/nodes/src/downspout/index.ts +++ b/packages/nodes/src/downspout/index.ts @@ -1,3 +1,4 @@ export { downspoutDefinition } from './definition' export { buildDownspoutGeometry } from './geometry' +export { downspoutCursorOffset, resolveDownspoutPlacement } from './placement' export { DownspoutNode } from './schema' diff --git a/packages/nodes/src/downspout/inspector-editors.tsx b/packages/nodes/src/downspout/inspector-editors.tsx index 3a8c1dcd9b..cf07626e3e 100644 --- a/packages/nodes/src/downspout/inspector-editors.tsx +++ b/packages/nodes/src/downspout/inspector-editors.tsx @@ -7,8 +7,9 @@ import { type GutterOutlet, useLiveNodeOverrides, useScene, + usesAutomaticDownspoutLength, } from '@pascal-app/core' -import { SliderControl } from '@pascal-app/editor' +import { SegmentedControl, SliderControl } from '@pascal-app/editor' /** * Position-along-the-eave editor for a downspout. The downspout's spot @@ -99,3 +100,95 @@ export function DownspoutPositionEditor({ node }: { node: DownspoutNode }) { /> ) } + +export function DownspoutLengthModeEditor({ + node, + onUpdate, +}: { + node: DownspoutNode + onUpdate: (patch: Partial) => void +}) { + return ( +
+

Length mode

+ + onUpdate({ lengthMode: value === 'to-ground' ? 'to-ground' : 'manual' }) + } + /> +
+ ) +} + +export function DownspoutShapeEditor({ + node, + onUpdate, +}: { + node: DownspoutNode + onUpdate: (patch: Partial) => void +}) { + return ( +
+ +
+ {(['round', 'rect'] as const).map((shape) => ( + + ))} +
+
+ ) +} + +export function DownspoutTerminalEditor({ + node, + onUpdate, +}: { + node: DownspoutNode + onUpdate: (patch: Partial) => void +}) { + return ( +
+ {(['splash', 'kickout', 'straight'] as const).map((terminal) => ( + + ))} +
+ ) +} diff --git a/packages/nodes/src/downspout/parametrics.test.ts b/packages/nodes/src/downspout/parametrics.test.ts index 61094faf9e..4e23af9f35 100644 --- a/packages/nodes/src/downspout/parametrics.test.ts +++ b/packages/nodes/src/downspout/parametrics.test.ts @@ -1,6 +1,12 @@ import { describe, expect, test } from 'bun:test' -import { DownspoutNode } from '@pascal-app/core' -import { downspoutParametrics } from './parametrics' +import { + DownspoutNode, + defaultDownspoutMetadata, + usesAutomaticDownspoutLength, +} from '@pascal-app/core' +import { downspoutDefinition } from './index' + +const downspoutParametrics = downspoutDefinition.parametrics! describe('downspout length mode', () => { test('switches an automatic downspout to manual when its length is edited', () => { @@ -10,3 +16,22 @@ describe('downspout length mode', () => { }) }) }) + +test('explicit automatic mode survives a simultaneous length patch', () => { + const node = DownspoutNode.parse({ length: 4, lengthMode: 'manual' }) + const patch = { length: 6, lengthMode: 'to-ground' as const } + const next = { ...node, ...patch } + const derived = downspoutParametrics.derive?.(next, patch, node) + expect(usesAutomaticDownspoutLength({ ...next, ...derived })).toBe(true) +}) + +test('legacy generated downspouts remain automatic and ordinary legacy drops remain manual', () => { + expect( + usesAutomaticDownspoutLength(DownspoutNode.parse({ metadata: defaultDownspoutMetadata() })), + ).toBe(true) + expect(usesAutomaticDownspoutLength(DownspoutNode.parse({}))).toBe(false) + const mode = downspoutParametrics + .groups!.flatMap((group) => group.fields) + .find((field) => field.key === 'lengthMode') + expect(mode?.kind).toBe('custom') +}) diff --git a/packages/nodes/src/downspout/parametrics.ts b/packages/nodes/src/downspout/parametrics.ts index 2ab301768b..0dbe084634 100644 --- a/packages/nodes/src/downspout/parametrics.ts +++ b/packages/nodes/src/downspout/parametrics.ts @@ -1,22 +1,27 @@ import type { ParametricDescriptor } from '@pascal-app/core' -import { DownspoutPositionEditor } from './inspector-editors' +import { + DownspoutLengthModeEditor, + DownspoutPositionEditor, + DownspoutShapeEditor, + DownspoutTerminalEditor, +} from './inspector-editors' import type { DownspoutNode } from './schema' export const downspoutParametrics: ParametricDescriptor = { - derive: (_next, patch) => ('length' in patch ? { lengthMode: 'manual' } : {}), + derive: (_next, patch) => + 'length' in patch && !('lengthMode' in patch) ? { lengthMode: 'manual' } : {}, groups: [ + { label: 'Style', fields: [{ key: 'shape', kind: 'custom', component: DownspoutShapeEditor }] }, + { + label: 'Bottom treatment', + fields: [{ key: 'terminal', kind: 'custom', component: DownspoutTerminalEditor }], + }, { label: 'Dimensions', fields: [ - { key: 'length', kind: 'number', unit: 'm', min: 0.1, max: 1000, step: 0.05 }, - { key: 'diameter', kind: 'number', unit: 'm', min: 0.02, max: 0.15, step: 0.005 }, - // Cross-section: follow the gutter profile, or force round / rect. - { - key: 'shape', - kind: 'enum', - options: ['auto', 'round', 'rect'], - display: 'segmented', - }, + { key: 'lengthMode', kind: 'custom', component: DownspoutLengthModeEditor }, + { key: 'length', kind: 'number', unit: 'm', min: 0.1, step: 0.05 }, + { key: 'diameter', kind: 'number', unit: 'm', min: 0.02, step: 0.005 }, ], }, { @@ -34,17 +39,9 @@ export const downspoutParametrics: ParametricDescriptor = { kind: 'number', unit: 'm', min: 0.3, - max: 3, step: 0.1, visibleIf: (n) => (n.strapStyle ?? 'band') !== 'none', }, - // Bottom treatment: splash block, kickout only, or straight to grade. - { - key: 'terminal', - kind: 'enum', - options: ['splash', 'kickout', 'straight'], - display: 'segmented', - }, ], }, { @@ -63,7 +60,7 @@ export const downspoutParametrics: ParametricDescriptor = { // auto-routed run buries into the wall (the wall isn't where the // roof overhang implies); 0 puts the pipe surface on the wall // face; large values pull the run back out toward the eave. - { key: 'standoff', kind: 'number', unit: 'm', min: 0, max: 0.6, step: 0.01 }, + { key: 'standoff', kind: 'number', unit: 'm', min: 0, step: 0.01 }, ], }, ], diff --git a/packages/nodes/src/downspout/placement.test.ts b/packages/nodes/src/downspout/placement.test.ts new file mode 100644 index 0000000000..d0b7a40977 --- /dev/null +++ b/packages/nodes/src/downspout/placement.test.ts @@ -0,0 +1,33 @@ +import { expect, test } from 'bun:test' +import { GutterNode } from '@pascal-app/core' +import { downspoutCursorOffset, resolveDownspoutPlacement } from './index' + +test('downspout placement snaps along the gutter and persists the fitted offset', () => { + const gutter = GutterNode.parse({ length: 2 }) + expect(resolveDownspoutPlacement(gutter, 0.37, 0.25)?.offset).toBe(0.25) + expect(resolveDownspoutPlacement(gutter, 0.37)?.offset).toBe(0.37) + expect(resolveDownspoutPlacement(gutter, 5)?.offset).toBeLessThan(1) + expect(resolveDownspoutPlacement(gutter, 0.37, 0.25, true)?.offset).toBe(0.37) +}) +test('overlapping outlet placement is rejected unless forced', () => { + const gutter = GutterNode.parse({ outlets: [{ id: 'existing', offset: 0, diameter: 0.07 }] }) + expect(resolveDownspoutPlacement(gutter, 0.02)?.valid).toBe(false) + expect(resolveDownspoutPlacement(gutter, 0.3)?.valid).toBe(true) + expect(resolveDownspoutPlacement(gutter, 0.02, 0, true)?.valid).toBe(true) +}) +test('curved gutter cursor offsets round-trip on either bend direction', () => { + for (const sign of [-1, 1]) { + const radius = 3 + const offset = 0.7 + const phi = offset / (sign * radius) + const centerZ = sign * radius + const radial = 0.05 - centerZ + const gutter = GutterNode.parse({ arc: { centerX: 0, centerZ, radius } }) + expect( + downspoutCursorOffset(gutter, -radial * Math.sin(phi), centerZ + radial * Math.cos(phi)), + ).toBeCloseTo(offset) + } +}) +test('a gutter too short for an outlet has no valid drop', () => { + expect(resolveDownspoutPlacement(GutterNode.parse({ length: 0.05 }), 0)).toBeNull() +}) diff --git a/packages/nodes/src/downspout/placement.ts b/packages/nodes/src/downspout/placement.ts new file mode 100644 index 0000000000..f584de470a --- /dev/null +++ b/packages/nodes/src/downspout/placement.ts @@ -0,0 +1,35 @@ +import type { GutterNode } from '@pascal-app/core' +import { resolveGutterOutletById } from '../gutter/outlet-lookup' + +/** Convert a point on the rendered arc back to distance along the unbent run. */ +export function downspoutCursorOffset(gutter: GutterNode, x: number, z: number): number { + if (!gutter.arc || !(gutter.arc.radius > 0)) return x + const { centerX, centerZ, radius } = gutter.arc + const sign = Math.sign(centerZ) || 1 + const angle = Math.atan2((x - centerX) * sign, (centerZ - z) * sign) + return centerX + angle * radius * sign +} + +export function resolveDownspoutPlacement( + gutter: GutterNode, + raw: number, + step = 0, + force = false, +) { + const diameter = 0.07 + const offset = !force && step > 0 ? Math.round(raw / step) * step : raw + const ghost = { ...gutter, arc: undefined, outlets: [{ id: 'preview', offset, diameter }] } + const outlet = resolveGutterOutletById(ghost, 'preview') + if (!outlet) return null + const finalOffset = outlet.x + const valid = + force || + !(gutter.outlets ?? []).some((existing) => { + const placement = resolveGutterOutletById({ ...gutter, arc: undefined }, existing.id) + return ( + placement && + Math.abs(placement.x - finalOffset) < placement.innerHalfX + outlet.innerHalfX + 0.02 + ) + }) + return { offset: finalOffset, diameter, valid } +} diff --git a/packages/nodes/src/downspout/tool.test.tsx b/packages/nodes/src/downspout/tool.test.tsx new file mode 100644 index 0000000000..fddcf26ca5 --- /dev/null +++ b/packages/nodes/src/downspout/tool.test.tsx @@ -0,0 +1,103 @@ +import { expect, test } from 'bun:test' +import { + BuildingNode, + emitter, + GutterNode, + LevelNode, + nodeRegistry, + RoofNode, + RoofSegmentNode, + registerNode, + sceneRegistry, + useScene, +} from '@pascal-app/core' +import { useEditor, usePlacementPreview } from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { act, create } from '@react-three/test-renderer' +import * as THREE from 'three' +import { downspoutDefinition } from './index' + +test('downspout placement creates its outlet atomically and rejects overlap', async () => { + const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window') + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: Object.assign(new EventTarget(), { + requestAnimationFrame: () => 0, + cancelAnimationFrame: () => {}, + }), + }) + const restoreRegistry = nodeRegistry._snapshot() + if (!nodeRegistry.get('downspout')) registerNode(downspoutDefinition) + const savedEditor = useEditor.getState() + useEditor.setState({ mode: 'build', tool: 'downspout', gridSnapStep: 0.25 }) + useEditor.getState().setSnappingMode('item', 'grid') + const savedScene = useScene.getState() + const savedSelection = useViewer.getState().selection + const building = BuildingNode.parse({}) + const level = LevelNode.parse({ parentId: building.id }) + const roof = RoofNode.parse({ parentId: level.id }) + const segment = RoofSegmentNode.parse({ parentId: roof.id, width: 20, depth: 20 }) + const gutter = GutterNode.parse({ parentId: segment.id, roofSegmentId: segment.id }) + segment.children = [gutter.id] + roof.children = [segment.id] + level.children = [roof.id] + building.children = [level.id] + useScene.setState({ + nodes: { + [building.id]: building, + [level.id]: level, + [roof.id]: roof, + [segment.id]: segment, + [gutter.id]: gutter, + }, + rootNodeIds: [building.id], + dirtyNodes: new Set(), + readOnly: false, + }) + useViewer.getState().setSelection({ buildingId: building.id, levelId: level.id }) + sceneRegistry.nodes.set(segment.id, new THREE.Group()) + sceneRegistry.nodes.set(gutter.id, new THREE.Group()) + const { default: Tool } = await downspoutDefinition.tool!() + const renderer = await create() + try { + const event = { + node: gutter, + position: [0.13, 3, 3.13] as [number, number, number], + stopPropagation: () => {}, + } + await act(async () => emitter.emit('gutter:move', event)) + let writes = 0 + const unsubscribe = useScene.subscribe((state, previous) => { + if (state.nodes !== previous.nodes) writes++ + }) + await act(async () => emitter.emit('gutter:click', event)) + unsubscribe() + expect(writes).toBe(1) + const placed = Object.values(useScene.getState().nodes).find( + (node) => node.type === 'downspout', + ) + if (placed?.type !== 'downspout') throw new Error('Missing downspout') + expect(placed.gutterId).toBe(gutter.id) + expect(placed.lengthMode).toBe('to-ground') + const updated = useScene.getState().nodes[gutter.id] as typeof gutter + expect(updated.outlets).toHaveLength(1) + expect(updated.outlets[0]!.offset).toBe(0.25) + expect(placed.outletId).toBe(updated.outlets[0]!.id) + await act(async () => emitter.emit('gutter:click', { ...event, node: updated })) + expect( + Object.values(useScene.getState().nodes).filter((node) => node.type === 'downspout'), + ).toHaveLength(1) + } finally { + restoreRegistry() + useEditor.setState(savedEditor) + await renderer.unmount() + if (originalWindow) Object.defineProperty(globalThis, 'window', originalWindow) + else Reflect.deleteProperty(globalThis, 'window') + sceneRegistry.nodes.delete(segment.id) + sceneRegistry.nodes.delete(gutter.id) + sceneRegistry.nodes.delete(building.id) + useScene.setState(savedScene) + useViewer.getState().setSelection(savedSelection) + usePlacementPreview.getState().clear() + } +}) diff --git a/packages/nodes/src/downspout/tool.tsx b/packages/nodes/src/downspout/tool.tsx index e9ede870f7..49d37ba361 100644 --- a/packages/nodes/src/downspout/tool.tsx +++ b/packages/nodes/src/downspout/tool.tsx @@ -7,11 +7,13 @@ import { type GutterEvent, type GutterNode, generateId, + type RoofNode, type RoofSegmentNode, + resolveAutomaticDownspoutLength, sceneRegistry, useScene, } from '@pascal-app/core' -import { triggerSFX } from '@pascal-app/editor' +import { isGridSnapActive, triggerSFX, useEditor } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useState } from 'react' import { Vector3 } from 'three' @@ -19,12 +21,15 @@ import { computeEaveY } from '../gutter/eave-snap' import { resolveGutterOutletById } from '../gutter/outlet-lookup' import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview' import { downspoutDefinition } from './definition' +import { downspoutCursorOffset, resolveDownspoutPlacement } from './placement' import DownspoutPreview from './preview' import { computeDownspoutRouting, type DownspoutRouting } from './routing' -const DEFAULT_OUTLET_DIAMETER = 0.07 - type PreviewTarget = { + roof: { position: [number, number, number]; rotation: number } + length: number + valid: boolean + offset: number segment: { position: [number, number, number]; rotation: number; eaveY: number } gutter: { position: [number, number, number]; rotation: number } outlet: { x: number; y: number; z: number; bore: number } @@ -68,7 +73,8 @@ const DownspoutTool = () => { const obj = sceneRegistry.nodes.get(gutter.id as AnyNodeId) if (!obj) return null obj.updateWorldMatrix(true, false) - return obj.worldToLocal(_hit.set(world[0], world[1], world[2])).x + const local = obj.worldToLocal(_hit.set(world[0], world[1], world[2])) + return downspoutCursorOffset(gutter, local.x, local.z) } const computeTarget = (event: GutterEvent): PreviewTarget | null => { @@ -80,16 +86,36 @@ const DownspoutTool = () => { const offset = cursorOffset(gutter, event.position) if (offset === null) return null + const placement = resolveDownspoutPlacement( + gutter, + offset, + isGridSnapActive() ? useEditor.getState().gridSnapStep : 0, + event.nativeEvent?.altKey === true, + ) + if (!placement) return null + const roof = segment.parentId + ? (useScene.getState().nodes[segment.parentId as AnyNodeId] as RoofNode) + : undefined + // Throwaway single-outlet gutter at the cursor so the lookup + // routing produce the exact pose the commit will store. const ghost: GutterNode = { ...gutter, - outlets: [{ id: 'preview', offset, diameter: DEFAULT_OUTLET_DIAMETER }], + outlets: [{ id: 'preview', offset: placement.offset, diameter: placement.diameter }], } const outlet = resolveGutterOutletById(ghost, 'preview') if (!outlet) return null return { + roof: { position: roof?.position ?? [0, 0, 0], rotation: roof?.rotation ?? 0 }, + length: resolveAutomaticDownspoutLength( + useScene.getState().nodes, + segment, + ghost, + placement.offset, + ), + valid: placement.valid, + offset: placement.offset, segment: { position: (segment.position ?? [0, 0, 0]) as [number, number, number], rotation: segment.rotation ?? 0, @@ -106,8 +132,8 @@ const DownspoutTool = () => { const updatePreview = (event: GutterEvent) => { const next = computeTarget(event) + setTarget(next) if (next) { - setTarget(next) event.stopPropagation() } } @@ -118,24 +144,19 @@ const DownspoutTool = () => { if (!segmentId) return const segment = useScene.getState().nodes[segmentId] as RoofSegmentNode | undefined if (!segment) return - const offset = cursorOffset(gutter, event.position) - if (offset === null) return - + const target = computeTarget(event) + if (!target?.valid) return + const offset = target.offset // Drill a new outlet at the clicked offset, then drop a downspout // linked to it. Both land in one undoable step. const outletId = generateId('outlet') - const outlets = [ - ...(gutter.outlets ?? []), - { id: outletId, offset, diameter: DEFAULT_OUTLET_DIAMETER }, - ] + const outlets = [...(gutter.outlets ?? []), { id: outletId, offset, diameter: 0.07 }] const state = useScene.getState() - state.updateNode(gutter.id as AnyNodeId, { outlets }) - state.dirtyNodes.add(gutter.id as AnyNodeId) const outlet = resolveGutterOutletById({ ...gutter, outlets }, outletId) if (!outlet) return // Drop from the gutter outlet (at eaveY − size) down to segment Y = 0. - const dropLength = Math.max(0.1, computeEaveY(segment) + outlet.y) + const dropLength = target.length const downspout = DownspoutNode.parse({ ...downspoutDefinition.defaults(), @@ -143,20 +164,30 @@ const DownspoutTool = () => { gutterId: gutter.id, outletId, length: dropLength, + lengthMode: 'to-ground', diameter: outlet.bore * 2, }) - state.createNode(downspout, segmentId) + state.applyNodeChanges({ + update: [{ id: gutter.id, data: { outlets } }], + create: [{ node: downspout, parentId: segmentId }], + }) + setTarget(null) state.dirtyNodes.add(segmentId) setSelection({ selectedIds: [downspout.id] }) triggerSFX('sfx:item-place') event.stopPropagation() } + const clear = () => setTarget(null) + emitter.on('gutter:leave', clear) + emitter.on('tool:cancel', clear) emitter.on('gutter:move', updatePreview) emitter.on('gutter:enter', updatePreview) emitter.on('gutter:click', onClick) return () => { + emitter.off('gutter:leave', clear) + emitter.off('tool:cancel', clear) emitter.off('gutter:move', updatePreview) emitter.off('gutter:enter', updatePreview) emitter.off('gutter:click', onClick) @@ -172,16 +203,23 @@ const DownspoutTool = () => { validTarget="gutter" /> {activeBuildingId && target && ( - - - - + + + + + + @@ -199,7 +237,7 @@ function previewNodeWithDefaults( return { ...base, diameter: target.outlet.bore * 2, - length: Math.max(0.1, target.segment.eaveY + target.outlet.y), + length: target.length, } as typeof base } diff --git a/packages/nodes/src/duct-segment/definition.ts b/packages/nodes/src/duct-segment/definition.ts index e39730bf8e..37ddef72dc 100644 --- a/packages/nodes/src/duct-segment/definition.ts +++ b/packages/nodes/src/duct-segment/definition.ts @@ -136,6 +136,9 @@ export const ductSegmentDefinition: NodeDefinition = { direction: unit(first, second), diameter: ductPortDiameterIn(n), system: n.system, + shape: n.shape, + width: n.width, + height: n.height, }, { id: 'end', @@ -143,6 +146,9 @@ export const ductSegmentDefinition: NodeDefinition = { direction: unit(last, prev), diameter: ductPortDiameterIn(n), system: n.system, + shape: n.shape, + width: n.width, + height: n.height, }, ] }, diff --git a/packages/nodes/src/duct-terminal/definition.ts b/packages/nodes/src/duct-terminal/definition.ts index ff32b20648..aaae1b08fe 100644 --- a/packages/nodes/src/duct-terminal/definition.ts +++ b/packages/nodes/src/duct-terminal/definition.ts @@ -1,6 +1,7 @@ import type { NodeDefinition } from '@pascal-app/core' import { buildDuctTerminalFloorplan } from './floorplan' import { buildDuctTerminalGeometry } from './geometry' +import { ductTerminalToolOptions } from './options' import { ductTerminalParametrics } from './parametrics' import { getDuctTerminalPorts } from './ports' import { DuctTerminalNode } from './schema' @@ -59,6 +60,7 @@ export const ductTerminalDefinition: NodeDefinition = { }, parametrics: ductTerminalParametrics, + toolOptions: ductTerminalToolOptions, geometry: buildDuctTerminalGeometry, geometryKey: (n) => diff --git a/packages/nodes/src/duct-terminal/index.ts b/packages/nodes/src/duct-terminal/index.ts index ede0e1c748..53d1a69bad 100644 --- a/packages/nodes/src/duct-terminal/index.ts +++ b/packages/nodes/src/duct-terminal/index.ts @@ -1,4 +1,5 @@ export { ductTerminalDefinition } from './definition' export { buildDuctTerminalGeometry } from './geometry' +export { resolveTerminalPortPlacement, terminalProfileForPort } from './placement' export { getDuctTerminalPorts } from './ports' export { DuctTerminalNode } from './schema' diff --git a/packages/nodes/src/duct-terminal/options.ts b/packages/nodes/src/duct-terminal/options.ts new file mode 100644 index 0000000000..cb752b34df --- /dev/null +++ b/packages/nodes/src/duct-terminal/options.ts @@ -0,0 +1,24 @@ +import type { ToolOption } from '@pascal-app/core' +import { useEditor } from '@pascal-app/editor' + +export const ductTerminalToolOptions: ToolOption[] = [ + { + id: 'terminalType', + label: 'Terminal', + choices: [ + { value: 'supply-register', label: 'Supply register' }, + { value: 'diffuser', label: 'Diffuser' }, + { value: 'return-grille', label: 'Return grille' }, + ], + value: () => + String(useEditor.getState().toolDefaults['duct-terminal']?.terminalType ?? 'supply-register'), + subscribe: (callback) => useEditor.subscribe(callback), + set: (value) => { + if (!['supply-register', 'diffuser', 'return-grille'].includes(value)) return + useEditor.getState().setToolDefaults('duct-terminal', { + ...useEditor.getState().toolDefaults['duct-terminal'], + terminalType: value, + }) + }, + }, +] diff --git a/packages/nodes/src/duct-terminal/placement.test.ts b/packages/nodes/src/duct-terminal/placement.test.ts new file mode 100644 index 0000000000..33013fb1c0 --- /dev/null +++ b/packages/nodes/src/duct-terminal/placement.test.ts @@ -0,0 +1,123 @@ +import { afterAll, expect, test } from 'bun:test' +import { + DuctSegmentNode, + DuctTerminalNode, + LevelNode, + type NodePort, + nodeRegistry, + portConnection, + registerNode, + snapPlanarPortPosition, +} from '@pascal-app/core' +import { + ductSegmentDefinition, + ductTerminalDefinition, + resolveTerminalPortPlacement, + terminalProfileForPort, +} from '../index' + +const node = DuctTerminalNode.parse({ collarDiameter: 8 }) +const target: NodePort = { + id: 'end', + position: [0.2, 0, 0], + direction: [0, 1, 0], + diameter: 8, + system: 'supply', +} + +test('terminal placement prefers a compatible port over a nearer wrong system or size', () => { + const near = { ...target, position: [0, 0, 0] as const } + const placed = resolveTerminalPortPlacement(node, [0, 0, 0], 0, [ + { ...near, system: 'return' }, + { ...near, diameter: 6 }, + target, + ])! + const collar = ductTerminalDefinition.ports!({ ...node, ...placed, rotation: placed.yaw })[0]! + expect(portConnection(collar, target).status).toBe('match') + expect(placed.position[0]).toBe(0.2) +}) + +test('terminal snaps on supported floor, wall and ceiling axes but rejects diagonal ports', () => { + for (const direction of [ + [0, 1, 0], + [0, -1, 0], + [1, 0, 0], + [0, 0, -1], + ] as const) { + const port = { ...target, direction } + const placed = resolveTerminalPortPlacement(node, [0, 0, 0], 0, [port])! + expect(placed).not.toBeNull() + const collar = ductTerminalDefinition.ports!({ ...node, ...placed, rotation: placed.yaw })[0]! + expect(portConnection(collar, port).status).toBe('match') + } + expect( + resolveTerminalPortPlacement(node, [0, 0, 0], 0, [{ ...target, direction: [1, 1, 0] }]), + ).toBeNull() +}) + +test('explicit matching changes terminal system and profile without mutating the input', () => { + const port = { ...target, system: 'return', shape: 'rect' as const, width: 12, height: 8 } + expect(resolveTerminalPortPlacement(node, [0, 0, 0], 0, [port])).toBeNull() + const patch = terminalProfileForPort(node, port)! + const matched = DuctTerminalNode.parse({ ...node, ...patch }) + expect(matched.terminalType).toBe('return-grille') + expect(matched.collarWidth).toBe(12) + expect(matched.collarHeight).toBe(8) + expect(node.terminalType).toBe('supply-register') + expect(resolveTerminalPortPlacement(matched, [0, 0, 0], 0, [port])).not.toBeNull() + expect(terminalProfileForPort(node, { ...port, width: undefined })).toBeNull() + expect(terminalProfileForPort(node, { ...port, system: 'waste' })).toBeNull() +}) + +const restoreRegistry = nodeRegistry._snapshot() +afterAll(restoreRegistry) +registerNode(ductTerminalDefinition) +registerNode(ductSegmentDefinition) + +test('planar movement snaps only to compatible ports at the same height and level', () => { + const level = LevelNode.parse({ level: 0 }) + const upper = LevelNode.parse({ level: 1 }) + const terminal = DuctTerminalNode.parse({ + parentId: level.id, + collarDiameter: 8, + position: [0, 0.12, 0], + }) + const run = DuctSegmentNode.parse({ + parentId: level.id, + diameter: 8, + path: [ + [0.3, -1, 0], + [0.3, 0, 0], + ], + }) + const nodes = { [level.id]: level, [upper.id]: upper, [terminal.id]: terminal, [run.id]: run } + const snap = (candidate = run) => + snapPlanarPortPosition( + terminal, + [0.1, 0], + { ...nodes, [run.id]: candidate }, + { systems: ['supply', 'return'] }, + ) + expect(snap()).toEqual([0.3, 0]) + expect(snap({ ...run, system: 'return' })).toBeNull() + expect(snap({ ...run, diameter: 6 })).toBeNull() + expect(snap({ ...run, parentId: upper.id })).toBeNull() + expect( + snap({ + ...run, + path: [ + [0.3, 1, 0], + [0.3, 2, 0], + ], + }), + ).toBeNull() + expect( + snap({ + ...run, + path: [ + [0.3, 1, 0], + [0.3, 0, 0], + ], + }), + ).toBeNull() +}) diff --git a/packages/nodes/src/duct-terminal/placement.ts b/packages/nodes/src/duct-terminal/placement.ts new file mode 100644 index 0000000000..91538b2a00 --- /dev/null +++ b/packages/nodes/src/duct-terminal/placement.ts @@ -0,0 +1,68 @@ +import { connectionCompatibility, type NodePort, portConnection } from '@pascal-app/core' +import { Euler, Quaternion, Vector3 } from 'three' +import { COLLAR_LENGTH, getDuctTerminalPorts, mountQuaternion } from './ports' +import { DuctTerminalNode } from './schema' + +export function resolveTerminalPortPlacement( + node: DuctTerminalNode, + position: [number, number, number], + yaw: number, + ports: readonly NodePort[], +) { + const profile = getDuctTerminalPorts(node)[0]! + let best: { + position: [number, number, number] + yaw: number + mount: DuctTerminalNode['mount'] + } | null = null + let distance = 0.5 + for (const port of ports) { + const nextDistance = Math.hypot(...port.position.map((value, i) => value - position[i]!)) + if (nextDistance > distance || connectionCompatibility(profile, port).status !== 'match') + continue + const dir = new Vector3(...port.direction).normalize() + const mount: DuctTerminalNode['mount'] = + dir.y > 0.7 ? 'floor' : dir.y < -0.7 ? 'ceiling' : 'wall' + const rotation = mount === 'wall' ? Math.atan2(dir.x, dir.z) : yaw + const transform = new Quaternion() + .setFromEuler(new Euler(0, rotation, 0)) + .multiply(mountQuaternion(mount)) + const offset = new Vector3(0, -COLLAR_LENGTH, 0).applyQuaternion(transform) + const placed = { + position: [ + port.position[0] - offset.x, + port.position[1] - offset.y, + port.position[2] - offset.z, + ] as [number, number, number], + yaw: rotation, + mount, + } + const collar = getDuctTerminalPorts({ ...node, ...placed, rotation })[0]! + if (portConnection(collar, port).status !== 'match') continue + best = placed + distance = nextDistance + } + return best +} + +/** Explicitly adopt a nearby air port's profile; never silently switch the terminal's system. */ +export function terminalProfileForPort( + node: DuctTerminalNode, + port: NodePort, +): Partial | null { + if (port.system !== 'supply' && port.system !== 'return') return null + if (port.shape && port.shape !== 'round' && (!port.width || !port.height)) return null + const patch = { + terminalType: + port.system === 'return' + ? ('return-grille' as const) + : node.terminalType === 'return-grille' + ? ('supply-register' as const) + : node.terminalType, + collarShape: port.shape ?? 'round', + ...(port.shape && port.shape !== 'round' + ? { collarWidth: port.width, collarHeight: port.height } + : { collarDiameter: port.diameter }), + } + return DuctTerminalNode.safeParse({ ...node, ...patch }).success ? patch : null +} diff --git a/packages/nodes/src/duct-terminal/tool.tsx b/packages/nodes/src/duct-terminal/tool.tsx index 70ac58bf8b..a4bd741d21 100644 --- a/packages/nodes/src/duct-terminal/tool.tsx +++ b/packages/nodes/src/duct-terminal/tool.tsx @@ -3,6 +3,7 @@ import { type AnyNodeId, type CeilingNode, + connectionCompatibility, DuctTerminalNode, emitter, type GridEvent, @@ -35,20 +36,19 @@ import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' import { useThree } from '@react-three/fiber' import { useEffect, useMemo, useRef, useState } from 'react' -import { Euler, Matrix3, Matrix4, Plane, Quaternion, Raycaster, Vector2, Vector3 } from 'three' +import { Matrix3, Matrix4, Plane, Raycaster, Vector2, Vector3 } from 'three' import { subscribeAccessorySnapping } from '../shared/accessory-snapping' import { ConnectionFeedback } from '../shared/connection-feedback' import { LevelOffsetGroup } from '../shared/level-offset-group' import { DUCT_PORT_SYSTEMS } from '../shared/port-systems' import { ductTerminalDefinition } from './definition' import { buildDuctTerminalGeometry } from './geometry' -import { COLLAR_LENGTH, getDuctTerminalPorts, mountQuaternion } from './ports' +import { resolveTerminalPortPlacement, terminalProfileForPort } from './placement' +import { getDuctTerminalPorts } from './ports' const PREVIEW_OPACITY = 0.55 /** R/T yaw step — 45°. */ const ROTATE_STEP_RAD = Math.PI / 4 -/** Snap radius (meters) for mating the collar onto a nearby duct port. */ -const PORT_SNAP_RADIUS_M = 0.5 type Mount = DuctTerminalNode['mount'] const MOUNT_CYCLE: Mount[] = ['floor', 'ceiling', 'wall'] @@ -58,19 +58,6 @@ function snap(value: number, step: number): number { return Math.round(value / step) * step } -/** - * Collar-port offset from the node origin for a given mount + yaw, in - * level-local meters — the same transform `def.ports` applies, so the - * placement tool can predict where the collar lands and shift the whole - * terminal to mate it onto a duct port. - */ -function collarOffset(mount: Mount, yaw: number): Vector3 { - const transform = new Quaternion() - .setFromEuler(new Euler(0, yaw, 0)) - .multiply(mountQuaternion(mount)) - return new Vector3(0, -COLLAR_LENGTH, 0).applyQuaternion(transform) -} - /** The active level's mesh, or null. Carries the building transform plus the * level's stacked elevation — the frame terminals are stored and parented in, * so cursor hits resolve to true level-local coords on every floor. */ @@ -90,67 +77,6 @@ type Placement = { snapped?: boolean } -/** Direction is "vertical" when its Y component dominates this much. */ -const VERTICAL_DOT = 0.7 - -/** - * Pick the mount that makes a collar mate onto a duct port pointing - * `dir` (the port's outward direction). The collar leaves the face along - * −Y in the canonical frame, so the mount rotation must turn −Y to face - * *into* the port (i.e. opposite `dir`): - * - port pointing up (a riser top) → collar must point down → **floor** - * - port pointing down (a ceiling drop) → collar points up → **ceiling** - * - port horizontal (a wall stub) → **wall**, yawed so the collar runs - * back along the port. `lockYaw` is set only for wall (floor / ceiling - * yaw is free — the user keeps spinning the face with R/T). - */ -function inferMountFromPort(dir: readonly [number, number, number]): { - mount: Mount - lockYaw: number | null -} { - const v = new Vector3(dir[0], dir[1], dir[2]) - if (v.lengthSq() < 1e-8) return { mount: 'floor', lockYaw: null } - v.normalize() - if (v.y > VERTICAL_DOT) return { mount: 'floor', lockYaw: null } - if (v.y < -VERTICAL_DOT) return { mount: 'ceiling', lockYaw: null } - // Wall collar dir after mount + yaw is (−sin yaw, 0, −cos yaw); set it - // opposite the port so the collar runs back into the wall stub. - return { mount: 'wall', lockYaw: Math.atan2(v.x, v.z) } -} - -/** - * If a duct port is within snap range of `position` in three dimensions, mate the register onto it: - * the port's direction *picks the mount* (floor / ceiling / wall) and, for - * walls, the yaw; the whole terminal then hops so its collar lands exactly - * on the port. Null when nothing is in range. `fallbackYaw` keeps the - * user's R/T face orientation for floor / ceiling mounts. - */ -function resolvePortSnap( - position: [number, number, number], - fallbackYaw: number, -): { position: [number, number, number]; mount: Mount; yaw: number } | null { - const levelId = useViewer.getState().selection.levelId - if (!levelId) return null - const port = findNearestPort3D( - position, - collectScenePorts({ systems: DUCT_PORT_SYSTEMS, levelId }), - PORT_SNAP_RADIUS_M, - ) - if (!port) return null - const { mount, lockYaw } = inferMountFromPort(port.direction) - const yaw = lockYaw ?? fallbackYaw - const offset = collarOffset(mount, yaw) - return { - position: [ - port.position[0] - offset.x, - port.position[1] - offset.y, - port.position[2] - offset.z, - ], - mount, - yaw, - } -} - /** * Click-place tool for duct terminals (registers / diffusers / grilles). * @@ -182,7 +108,12 @@ const DuctTerminalTool = () => { DuctTerminalNode.parse({ ...ductTerminalDefinition.defaults(), ...toolDefaults, - name: 'Register', + name: + toolDefaults?.terminalType === 'return-grille' + ? 'Return grille' + : toolDefaults?.terminalType === 'diffuser' + ? 'Diffuser' + : 'Supply register', }), [toolDefaults], ) @@ -206,7 +137,7 @@ const DuctTerminalTool = () => { mountRef.current = configuredNode.mount setMount(configuredNode.mount) setPlacement(null) - }, [configuredNode.mount]) + }, [configuredNode]) useEffect(() => { if (!activeLevelId) return @@ -294,7 +225,12 @@ const DuctTerminalTool = () => { // raw-cursor `'off'` mode bypasses it. const snapEnabled = isGridSnapActive() || isMagneticSnapActive() if (snapEnabled) { - const mated = resolvePortSnap(position, yawRef.current) + const mated = resolveTerminalPortPlacement( + configuredNode, + position, + yawRef.current, + collectScenePorts({ systems: DUCT_PORT_SYSTEMS, levelId: activeLevelId }), + ) if (mated) { return { position: mated.position, yaw: mated.yaw, mount: mated.mount, snapped: true } } @@ -306,7 +242,12 @@ const DuctTerminalTool = () => { const terminal = DuctTerminalNode.parse({ ...ductTerminalDefinition.defaults(), ...toolDefaults, - name: 'Register', + name: + toolDefaults?.terminalType === 'return-grille' + ? 'Return grille' + : toolDefaults?.terminalType === 'diffuser' + ? 'Diffuser' + : 'Supply register', mount: p.mount, position: p.position, rotation: p.yaw, @@ -332,7 +273,14 @@ const DuctTerminalTool = () => { { applySnap: isMagneticSnapActive(), bypass: !isMagneticSnapActive() }, ) const snapEnabled = isGridSnapActive() || isMagneticSnapActive() - const mated = snapEnabled ? resolvePortSnap(position, yawRef.current) : null + const mated = snapEnabled + ? resolveTerminalPortPlacement( + configuredNode, + position, + yawRef.current, + collectScenePorts({ systems: DUCT_PORT_SYSTEMS, levelId: activeLevelId }), + ) + : null return mated ? { position: mated.position, yaw: mated.yaw, mount: mated.mount, snapped: true } : { position, yaw: yawRef.current, mount: 'floor' } @@ -485,7 +433,7 @@ const DuctTerminalTool = () => { window.removeEventListener('keydown', onKeyDown, true) clearDrawAlignment() } - }, [activeLevelId, camera, gl, toolDefaults]) + }, [activeLevelId, camera, gl, toolDefaults, configuredNode]) if (!activeLevelId || !placement) return null @@ -510,6 +458,13 @@ const DuctTerminalTool = () => { rotation: placement.yaw, mount: effectiveMount, })[0]! + const nearby = findNearestPort3D( + collar.position, + collectScenePorts({ systems: DUCT_PORT_SYSTEMS, levelId: activeLevelId }), + 0.5, + ) + const mismatch = nearby && connectionCompatibility(collar, nearby).status !== 'match' + const matchingProfile = mismatch ? terminalProfileForPort(configuredNode, nearby) : null return ( @@ -536,6 +491,21 @@ const DuctTerminalTool = () => { )} + {matchingProfile && ( + + )} Mount {mountLabel} · diff --git a/packages/nodes/src/eyebrow-vent/__tests__/geometry.test.ts b/packages/nodes/src/eyebrow-vent/__tests__/geometry.test.ts index fe7cc3c152..1d00ddd92b 100644 --- a/packages/nodes/src/eyebrow-vent/__tests__/geometry.test.ts +++ b/packages/nodes/src/eyebrow-vent/__tests__/geometry.test.ts @@ -1,6 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { buildEyebrowVentGeometry } from '../geometry' -import { EyebrowVentNode } from '../schema' +import { buildEyebrowVentGeometry, EyebrowVentNode } from '../index' function allFinite(geo: { getAttribute: (n: string) => { array: ArrayLike } }): boolean { const arr = geo.getAttribute('position').array @@ -64,3 +63,51 @@ describe('buildEyebrowVentGeometry', () => { } }) }) + +test('half-round vents render the requested number of separate louver blades', () => { + for (const count of [0, 1, 2, 3, 4, 8]) { + const geometry = buildEyebrowVentGeometry( + EyebrowVentNode.parse({ style: 'half-round', louverCount: count }), + ) + const positions = geometry.getAttribute('position') + // Blades are disconnected solids in the front material group. Counting + // their disjoint vertical ranges checks the visible result, not tessellation. + const ranges: [number, number][] = [] + for (const group of geometry.groups.filter((group) => group.materialIndex === 1)) { + for (let i = group.start; i < group.start + group.count; i += 3) { + // Ignore the recessed backing so only the separate blades are counted. + if (Math.max(positions.getZ(i), positions.getZ(i + 1), positions.getZ(i + 2)) < 0.241) + continue + const ys = [positions.getY(i), positions.getY(i + 1), positions.getY(i + 2)] + ranges.push([Math.min(...ys), Math.max(...ys)]) + } + } + ranges.sort((a, b) => a[0] - b[0]) + let components = 0 + let high = -Infinity + for (const [low, top] of ranges) { + if (low > high + 1e-6) components++ + high = Math.max(high, top) + } + expect(components).toBe(count) + geometry.dispose() + } +}) + +for (const style of ['scoop', 'half-round', 'slant-box'] as const) { + test(`${style} respects requested height and contains louvers in a shallow hood`, () => { + for (const louverCount of [0, 1, 8]) { + const node = EyebrowVentNode.parse({ style, width: 0.2, depth: 0.15, height: 2, louverCount }) + const geometry = buildEyebrowVentGeometry(node) + geometry.computeBoundingBox() + const bounds = geometry.boundingBox! + expect(bounds.max.y).toBeCloseTo(node.height, 6) + expect(bounds.min.y).toBeGreaterThanOrEqual(-1e-6) + expect(bounds.max.z).toBeLessThanOrEqual(node.depth / 2 + 1e-6) + expect(bounds.min.z).toBeGreaterThanOrEqual(-node.depth / 2 - 1e-6) + expect(bounds.max.x).toBeLessThanOrEqual(node.width / 2 + 1e-6) + expect(geometry.groups.some((g) => g.materialIndex === 1 && g.count > 0)).toBe(true) + geometry.dispose() + } + }) +} diff --git a/packages/nodes/src/eyebrow-vent/definition.ts b/packages/nodes/src/eyebrow-vent/definition.ts index 010df84785..2a3e8ec6d7 100644 --- a/packages/nodes/src/eyebrow-vent/definition.ts +++ b/packages/nodes/src/eyebrow-vent/definition.ts @@ -152,11 +152,13 @@ export const eyebrowVentDefinition: NodeDefinition = { preview: () => import('./preview'), tool: () => import('./tool'), + snapProfile: 'item', affordanceTools: { move: () => import('./move-tool'), }, toolHints: [ { key: 'Left click', label: 'Place eyebrow vent on roof' }, + { key: 'Alt', label: 'Place freely' }, { key: 'Esc', label: 'Cancel' }, ], @@ -170,6 +172,6 @@ export const eyebrowVentDefinition: NodeDefinition = { mcp: { description: - 'A low curved eyebrow vent on a roof segment — a lens-shaped hood with an optional louvered front. Parametric width (span) / depth / height.', + 'A low curved eyebrow vent on a roof segment — styles scoop, half-round and slant-box, with framed recessed screens and 0–8 louvers. Parametric width/depth/height; backRatio sets the slant-box rear height fraction.', }, } diff --git a/packages/nodes/src/eyebrow-vent/floorplan.ts b/packages/nodes/src/eyebrow-vent/floorplan.ts index d550d87b93..87b3faa189 100644 --- a/packages/nodes/src/eyebrow-vent/floorplan.ts +++ b/packages/nodes/src/eyebrow-vent/floorplan.ts @@ -61,8 +61,8 @@ export function buildEyebrowVentFloorplan( const fillOpacity = showSelectedChrome ? 0.55 : 0.7 const lineWidth = showSelectedChrome ? 0.03 : 0.02 - const hw = Math.max(node.width, 0.1) / 2 - const hd = Math.max(node.depth, 0.1) / 2 + const hw = Math.max(node.width, 0.15) / 2 + const hd = Math.max(node.depth, 0.15) / 2 const rect = (halfX: number, halfZ: number): FloorplanPoint[] => [ toPlan(-halfX, -halfZ), @@ -74,7 +74,17 @@ export function buildEyebrowVentFloorplan( const children: FloorplanGeometry[] = [] // Hood footprint outline + hit target (no flashing plate). - const hood = rect(hw, hd) + const hood = + node.style === 'scoop' + ? [ + ...Array.from({ length: 25 }, (_, i) => + toPlan(hw * Math.cos(((i / 24) * Math.PI) / 2), hd - (i / 24) * 2 * hd), + ), + ...Array.from({ length: 25 }, (_, i) => + toPlan(-hw * Math.cos((((24 - i) / 24) * Math.PI) / 2), hd - ((24 - i) / 24) * 2 * hd), + ), + ] + : rect(hw, hd) children.push({ kind: 'polygon', points: hood, diff --git a/packages/nodes/src/eyebrow-vent/geometry.ts b/packages/nodes/src/eyebrow-vent/geometry.ts index a3f8112f99..c135529c55 100644 --- a/packages/nodes/src/eyebrow-vent/geometry.ts +++ b/packages/nodes/src/eyebrow-vent/geometry.ts @@ -97,8 +97,8 @@ function addScoop( const a = w / 2 const b = h const zF = d / 2 - const NZ = 20 - const NF = 18 + const NZ = 24 + const NF = 32 // Lofted half-ellipse, full at the front (z = zF) tapering to a rounded // nose at the back. `cos(v·π/2)` gives a smooth falloff to zero. @@ -135,9 +135,7 @@ function addScoop( } // Horizontal louvers filling the front half-ellipse opening. - const frontStart = p.length / 3 - addArchLouvers(p, n, uv, a, b, yB, zF - d * 0.04, slats) - return frontStart + return addArchOpening(p, n, uv, a, b, yB, zF, d, slats) } // ─── Style: half-round (D-shaped louver vent) ───────────────────────────── @@ -153,12 +151,10 @@ function addHalfRound( slats: number, ): number { const a = w / 2 - // Cap the crown at a true half-round — never bulge past a semicircle, so the - // top reads as a clean, smaller-radius arch. `height` flattens it further. - const b = Math.min(h, a) + const b = h const zF = d / 2 const zB = -d / 2 - const NF = 20 + const NF = 32 const ringF = halfRing(a, b, yB, zF, 1, NF) const ringB = halfRing(a, b, yB, zB, 1, NF) @@ -189,10 +185,8 @@ function addHalfRound( pushTri(p, n, uv, backCenter, ringB[j + 1]!, ringB[j]!, [0, 0, -1]) } - // Louvered front face (a slat count bumped up — the D-vent reads denser). - const frontStart = p.length / 3 - addArchLouvers(p, n, uv, a, b, yB, zF - d * 0.04, slats > 0 ? Math.max(slats, 4) : 0) - return frontStart + // Louvered front face. + return addArchOpening(p, n, uv, a, b, yB, zF, d, slats) } // ─── Style: slant-box (low hooded box) ──────────────────────────────────── @@ -244,9 +238,13 @@ function addSlantBox( pushQuad(p, n, uv, [-hw, oB, zF], [oL, oB, zF], [oL, oT, zF], [-hw, oT, zF], [0, 0, 1]) // left pushQuad(p, n, uv, [oR, oB, zF], [hw, oB, zF], [hw, oT, zF], [oR, oT, zF], [0, 0, 1]) // right - // Recessed screen panel at the back of the pocket (blocks see-through). - const frontStart = p.length / 3 const screenZ = zF - d * 0.2 + pushQuad(p, n, uv, [oL, oT, zF], [oR, oT, zF], [oR, oT, screenZ], [oL, oT, screenZ], [0, -1, 0]) + pushQuad(p, n, uv, [oL, oB, zF], [oR, oB, zF], [oR, oB, screenZ], [oL, oB, screenZ], [0, 1, 0]) + pushQuad(p, n, uv, [oL, oB, zF], [oL, oT, zF], [oL, oT, screenZ], [oL, oB, screenZ], [1, 0, 0]) + pushQuad(p, n, uv, [oR, oB, zF], [oR, oT, zF], [oR, oT, screenZ], [oR, oB, screenZ], [-1, 0, 0]) + // The backing remains visible when louvers are disabled. + const frontStart = p.length / 3 pushQuad( p, n, @@ -260,7 +258,54 @@ function addSlantBox( // Horizontal louvers inside the pocket — bounded by the opening in height // and recessed in depth between the frame face and the screen. - addRectLouvers(p, n, uv, oR, oB, oT, zF - d * 0.07, slats) + addRectLouvers(p, n, uv, oR, oB, oT, zF - d * 0.07, slats, d * 0.1) + return frontStart +} + +function addArchOpening( + p: number[], + n: number[], + uv: number[], + a: number, + b: number, + yB: number, + zF: number, + depth: number, + slats: number, +): number { + const rim = Math.min(a, b) * 0.08 + const innerA = (a - rim) * 0.98, + innerB = b - 2 * rim + const recess = depth * 0.1 + const outer = halfRing(a, b, yB, zF, 1, 32) + const inner = halfRing(innerA, innerB, yB + rim, zF, 1, 32) + const back = halfRing(innerA, innerB, yB + rim, zF - recess, 1, 32) + addBand(p, n, uv, outer, inner, 32, () => [0, 0, 1]) + addBand(p, n, uv, inner, back, 32, (a, _, c) => [-(a[0]! + c[0]!), -(a[1]! + c[1]! - 2 * yB), 0]) + pushQuad( + p, + n, + uv, + [-a, yB, zF], + [a, yB, zF], + [innerA, yB + rim, zF], + [-innerA, yB + rim, zF], + [0, 0, 1], + ) + pushQuad( + p, + n, + uv, + [-innerA, yB + rim, zF], + [innerA, yB + rim, zF], + [innerA, yB + rim, zF - recess], + [-innerA, yB + rim, zF - recess], + [0, 1, 0], + ) + const frontStart = p.length / 3 + const center = [0, yB + rim, zF - recess] + for (let i = 0; i < 32; i++) pushTri(p, n, uv, center, back[i]!, back[i + 1]!, [0, 0, 1]) + addArchLouvers(p, n, uv, innerA, innerB, yB + rim, zF - recess * 0.25, slats, recess * 0.6) return frontStart } @@ -277,11 +322,12 @@ function addArchLouvers( yB: number, z: number, slats: number, + maxBack: number, ): void { if (slats <= 0) return const drop = (b / (slats + 1)) * 0.55 - const back = Math.max(0.012, b * 0.12) - const thick = Math.max(0.004, drop * 0.32) + const back = Math.min(maxBack, Math.max(0.012, b * 0.12)) + const thick = Math.min(maxBack * 0.2, Math.max(0.004, drop * 0.32)) for (let k = 1; k <= slats; k++) { const s = k / (slats + 1) // height fraction up the semicircle (sin φ) const y = yB + b * s @@ -311,12 +357,13 @@ function addRectLouvers( yTop: number, z: number, slats: number, + maxBack: number, ): void { if (slats <= 0) return const span = yTop - yB const drop = (span / (slats + 1)) * 0.55 - const back = Math.max(0.012, span * 0.18) - const thick = Math.max(0.004, drop * 0.32) + const back = Math.min(maxBack, Math.max(0.012, span * 0.18)) + const thick = Math.min(maxBack * 0.2, Math.max(0.004, drop * 0.32)) for (let k = 1; k <= slats; k++) { const y = yB + (span * k) / (slats + 1) addSlab( diff --git a/packages/nodes/src/eyebrow-vent/index.ts b/packages/nodes/src/eyebrow-vent/index.ts index 896419e29e..2548344e68 100644 --- a/packages/nodes/src/eyebrow-vent/index.ts +++ b/packages/nodes/src/eyebrow-vent/index.ts @@ -1,3 +1,4 @@ export { eyebrowVentDefinition } from './definition' export { buildEyebrowVentGeometry } from './geometry' +export { resolveEyebrowVentPlacement } from './placement' export { EyebrowVentNode } from './schema' diff --git a/packages/nodes/src/eyebrow-vent/move-tool.tsx b/packages/nodes/src/eyebrow-vent/move-tool.tsx index 8798e441d3..d7e20644b4 100644 --- a/packages/nodes/src/eyebrow-vent/move-tool.tsx +++ b/packages/nodes/src/eyebrow-vent/move-tool.tsx @@ -11,34 +11,40 @@ import { useScene, } from '@pascal-app/core' import { + commitFreshPlacementSubtree, consumePlacementDragRelease, + isGridSnapActive, + isMagneticSnapActive, markToolCancelConsumed, + stripPlacementMetadataFlags, triggerSFX, useEditor, } from '@pascal-app/editor' +import { getRoofOuterSurfaceFrameAtPoint } from '@pascal-app/viewer' import { useCallback, useEffect, useState } from 'react' import * as THREE from 'three' import { createRelativeRoofDrag, type RelativeRoofDragTarget, roofSegmentLocalToBuildingLocal, - snapRelativeRoofDragTarget, } from '../shared/relative-roof-drag' -import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' +import { surfaceQuatFromNormal } from '../shared/roof-surface' import { clearRoofSurfacePlacementGuides, publishRoofSurfaceNodePlacementGuides, snapRoofSurfaceNodeTarget, } from '../shared/roof-surface-placement-guides' +import { resolveEyebrowVentPlacement } from './placement' import EyebrowVentPreview from './preview' /** - * Eyebrow-vent move tool. Mirrors the box-vent / cupola move flow: the - * original mesh hides during the drag, a 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 (reparenting between segments when - * needed). Cancel restores the original transform, or deletes a freshly-cloned - * vent. + * Eyebrow-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 MoveEyebrowVentTool({ node }: { node: EyebrowVentNode }) { const exitMoveMode = useCallback(() => { @@ -47,6 +53,7 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode }) const [previewPos, setPreviewPos] = useState<[number, number, number] | null>(null) const [previewSurfaceQuat, setPreviewSurfaceQuat] = useState(null) + const [previewValid, setPreviewValid] = useState(true) const [previewYaw, setPreviewYaw] = useState(0) useEffect(() => { @@ -71,6 +78,15 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode }) let lastSnap: [number, number] | null = null let lastTarget: RelativeRoofDragTarget | null = null let committed = false + let forcePlacement = false + const originalSegment = original.roofSegmentId + ? (useScene.getState().nodes[original.roofSegmentId as AnyNodeId] as RoofSegmentNode) + : undefined + const roofOffset = originalSegment + ? original.position[1] - + getRoofOuterSurfaceFrameAtPoint(originalSegment, original.position[0], original.position[2]) + .point.y + : 0 const roofDrag = createRelativeRoofDrag(original) const clearTarget = () => { @@ -84,11 +100,38 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode }) const resolveSnappedTarget = (event: RoofEvent): RelativeRoofDragTarget | null => { const rawTarget = roofDrag.resolve(event) if (!rawTarget) return null - return snapRoofSurfaceNodeTarget({ - target: snapRelativeRoofDragTarget(rawTarget, event.nativeEvent?.shiftKey === true), + forcePlacement = event.nativeEvent?.altKey === true + let placement = resolveEyebrowVentPlacement( + rawTarget.segment, node, - bypass: event.nativeEvent?.shiftKey === true, - }) + rawTarget.localX, + rawTarget.localZ, + { + roofOffset, + gridStep: !forcePlacement && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0, + }, + ) + let target = { + ...rawTarget, + localX: placement.position[0], + localY: placement.position[1], + localZ: placement.position[2], + } + if (!forcePlacement && isMagneticSnapActive()) { + target = snapRoofSurfaceNodeTarget({ target, node }) + placement = resolveEyebrowVentPlacement( + target.segment, + node, + target.localX, + target.localZ, + { + roofOffset, + }, + ) + target = { ...target, localY: placement.position[1] } + } + setPreviewValid(placement.valid || forcePlacement) + return target } const updatePreview = (event: RoofEvent) => { @@ -102,14 +145,19 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode }) const sx = Math.round(target.localX * 20) / 20 const sz = Math.round(target.localZ * 20) / 20 if ( - event.nativeEvent?.shiftKey !== true && + !forcePlacement && + (isGridSnapActive() || isMagneticSnapActive()) && (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) ) { triggerSFX('sfx:grid-snap') lastSnap = [sx, sz] } - const normal = getAnalyticalNormal(target.localX, target.localZ, target.segment) + const normal = getRoofOuterSurfaceFrameAtPoint( + target.segment, + target.localX, + target.localZ, + ).normal setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion())) setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0)) setPreviewPos( @@ -130,39 +178,34 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode }) const onRoofClick = (event: RoofEvent) => { if (committed) return - const target = lastTarget ?? resolveSnappedTarget(event) + const target = event.node ? resolveSnappedTarget(event) : lastTarget if (!target) return + if ( + !forcePlacement && + !resolveEyebrowVentPlacement(target.segment, node, target.localX, target.localZ, { + roofOffset, + }).valid + ) + return committed = true const targetSegmentId = target.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, { + const patch = { roofSegmentId: targetSegmentId, parentId: targetSegmentId, - position: [target.localX, target.localY, target.localZ], + position: [target.localX, target.localY, target.localZ] as [number, number, number], rotation: original.rotation, visible: true, - metadata: {}, - }) - useScene.temporal.getState().pause() + metadata: stripPlacementMetadataFlags(original.metadata) as EyebrowVentNode['metadata'], + } + if (isNew) { + commitFreshPlacementSubtree(node.id as AnyNodeId, patch) + } else { + useScene.temporal.getState().resume() + st.updateNode(node.id as AnyNodeId, patch) + useScene.temporal.getState().pause() + } st.dirtyNodes.add(targetSegmentId) st.dirtyNodes.add(node.id as AnyNodeId) @@ -178,6 +221,8 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode }) 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 @@ -238,6 +283,9 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode }) emitter.off('tool:cancel', onCancel) window.removeEventListener('pointerup', onPlacementDragPointerUp) + // 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 clearRoofSurfacePlacementGuides() @@ -251,7 +299,7 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode }) - + diff --git a/packages/nodes/src/eyebrow-vent/panel.tsx b/packages/nodes/src/eyebrow-vent/panel.tsx index ab49bf9f98..c484f2e9ae 100644 --- a/packages/nodes/src/eyebrow-vent/panel.tsx +++ b/packages/nodes/src/eyebrow-vent/panel.tsx @@ -3,7 +3,6 @@ import { type AnyNode, type AnyNodeId, - getActiveRoofHeight, type RoofSegmentNode, useLiveNodeOverrides, useScene, @@ -14,14 +13,14 @@ import { duplicateNodeAndPickUp, PanelSection, PanelWrapper, - SegmentedControl, SliderControl, triggerSFX, useEditor, } from '@pascal-app/editor' -import { useViewer } from '@pascal-app/viewer' +import { getRoofOuterSurfaceFrameAtPoint, useViewer } from '@pascal-app/viewer' import { Copy, Move, Trash2 } from 'lucide-react' import { useCallback } from 'react' +import { resolveEyebrowVentPlacement } from './placement' import type { EyebrowVentNode } from './schema' /** @@ -97,15 +96,6 @@ export default function EyebrowVentPanel() { 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) @@ -117,24 +107,40 @@ export default function EyebrowVentPanel() { if (!(node && node.type === 'eyebrow-vent' && selectedId)) return null + const surfaceY = (x: number, z: number) => + segment ? getRoofOuterSurfaceFrameAtPoint(segment, x, z).point.y : 0 + const offset = node.position[1] - surfaceY(node.position[0], node.position[2]) + const at = (x: number, z: number): [number, number, number] => [x, surfaceY(x, z) + offset, z] + return ( - handleUpdate({ style: v as EyebrowVentNode['style'] })} - options={[ - { label: 'Scoop', value: 'scoop' }, - { label: 'Half-round', value: 'half-round' }, - { label: 'Slant-box', value: 'slant-box' }, - ]} - value={node.style ?? 'scoop'} - /> +
+ {(['scoop', 'half-round', 'slant-box'] as const).map((style) => ( + + ))} +
+
+ +

+ Zero louvers leaves the recessed screen visible. +

{node.style === 'slant-box' ? ( previewProp({ backRatio: v })} - onCommit={(v) => handleUpdate({ backRatio: v })} + label="Rear height" + max={100} + min={15} + onChange={(v) => previewProp({ backRatio: v / 100 })} + onCommit={(v) => handleUpdate({ backRatio: v / 100 })} precision={2} restoreOnCommit={false} - step={0.05} - value={Math.round((node.backRatio ?? 0.5) * 100) / 100} + step={1} + unit="%" + value={Math.round((node.backRatio ?? 0.5) * 100)} /> ) : null}
@@ -164,8 +174,7 @@ export default function EyebrowVentPanel() { previewProp({ width: v })} onCommit={(v) => handleUpdate({ width: v })} precision={2} @@ -176,8 +185,7 @@ export default function EyebrowVentPanel() { /> previewProp({ depth: v })} onCommit={(v) => handleUpdate({ depth: v })} precision={2} @@ -188,8 +196,7 @@ export default function EyebrowVentPanel() { /> previewProp({ height: v })} onCommit={(v) => handleUpdate({ height: v })} precision={2} @@ -200,17 +207,54 @@ export default function EyebrowVentPanel() { /> - + +
+ + +
+
+ - previewProp({ position: [v, node.position[1] ?? 0, node.position[2] ?? 0] }) - } - onCommit={(v) => - handleUpdate({ position: [v, node.position[1] ?? 0, node.position[2] ?? 0] }) - } + onChange={(v) => previewProp({ position: at(v, node.position[2]) })} + onCommit={(v) => handleUpdate({ position: at(v, node.position[2]) })} precision={2} restoreOnCommit={false} step={0.05} @@ -218,34 +262,37 @@ export default function EyebrowVentPanel() { value={node.position[0] ?? 0} /> - previewProp({ position: [node.position[0] ?? 0, v, node.position[2] ?? 0] }) + previewProp({ + position: [ + node.position[0], + surfaceY(node.position[0], node.position[2]) + v, + node.position[2], + ], + }) } onCommit={(v) => - handleUpdate({ position: [node.position[0] ?? 0, v, node.position[2] ?? 0] }) + handleUpdate({ + position: [ + node.position[0], + surfaceY(node.position[0], node.position[2]) + v, + node.position[2], + ], + }) } precision={2} restoreOnCommit={false} step={0.05} unit="m" - value={node.position[1] ?? 0} + value={offset} /> - previewProp({ position: [node.position[0] ?? 0, node.position[1] ?? 0, v] }) - } - onCommit={(v) => - handleUpdate({ position: [node.position[0] ?? 0, node.position[1] ?? 0, v] }) - } + onChange={(v) => previewProp({ position: at(node.position[0], v) })} + onCommit={(v) => handleUpdate({ position: at(node.position[0], v) })} precision={2} restoreOnCommit={false} step={0.05} diff --git a/packages/nodes/src/eyebrow-vent/placement.test.ts b/packages/nodes/src/eyebrow-vent/placement.test.ts new file mode 100644 index 0000000000..38854a7b8a --- /dev/null +++ b/packages/nodes/src/eyebrow-vent/placement.test.ts @@ -0,0 +1,52 @@ +import { expect, test } from 'bun:test' +import { RoofSegmentNode } from '@pascal-app/core' +import { getRoofOuterSurfaceFrameAtPoint } from '@pascal-app/viewer' +import { EyebrowVentNode, eyebrowVentDefinition, resolveEyebrowVentPlacement } from './index' + +const segment = RoofSegmentNode.parse({ width: 8, depth: 6, roofType: 'gable' }) +const vent = EyebrowVentNode.parse(eyebrowVentDefinition.defaults()) + +test('placement snaps both coordinates and seats the vent on the finished roof', () => { + const placed = resolveEyebrowVentPlacement(segment, vent, 0.13, 1.37, { + gridStep: 0.25, + alignDownslope: true, + }) + expect(placed.position[0]).toBe(0.25) + expect(placed.position[2]).toBe(1.25) + expect(placed.position[1]).toBeCloseTo( + getRoofOuterSurfaceFrameAtPoint(segment, 0.25, 1.25).point.y, + ) + expect(placed.valid).toBe(true) + expect(resolveEyebrowVentPlacement(segment, vent, 0.13, 1.37).position[0]).toBe(0.13) +}) + +test('vents face downslope on opposite faces while moves preserve a custom rotation and roof offset', () => { + const front = resolveEyebrowVentPlacement(segment, vent, 0, 1, { alignDownslope: true }) + const back = resolveEyebrowVentPlacement(segment, vent, 0, -1, { alignDownslope: true }) + expect(Math.abs(front.rotation - back.rotation)).toBeCloseTo(Math.PI) + const moved = resolveEyebrowVentPlacement(segment, { ...vent, rotation: 0.7 }, 1, 2, { + roofOffset: 0.1, + }) + expect(moved.rotation).toBe(0.7) + expect(moved.position[1]).toBeCloseTo( + getRoofOuterSurfaceFrameAtPoint(segment, 1, 2).point.y + 0.1, + ) +}) + +test('the whole hood footprint must fit, including ridges, eaves and trimmed edges', () => { + expect(resolveEyebrowVentPlacement(segment, vent, 0, 0).valid).toBe(false) + expect(resolveEyebrowVentPlacement(segment, vent, 0, 3.5).valid).toBe(false) + const trimmed = RoofSegmentNode.parse({ ...segment, trim: { right: 2 } }) + expect(resolveEyebrowVentPlacement(trimmed, vent, 1.9, 1).valid).toBe(false) + expect(resolveEyebrowVentPlacement(trimmed, vent, 1.5, 1).valid).toBe(true) + const flat = RoofSegmentNode.parse({ + roofType: 'flat', + width: 8, + depth: 6, + overhang: 0, + gableOverhang: 0, + wallThickness: 0, + }) + expect(resolveEyebrowVentPlacement(flat, { ...vent, width: 0.5 }, 3.7, 0).valid).toBe(true) + expect(resolveEyebrowVentPlacement(flat, { ...vent, width: 1 }, 3.7, 0).valid).toBe(false) +}) diff --git a/packages/nodes/src/eyebrow-vent/placement.ts b/packages/nodes/src/eyebrow-vent/placement.ts new file mode 100644 index 0000000000..631cf54107 --- /dev/null +++ b/packages/nodes/src/eyebrow-vent/placement.ts @@ -0,0 +1,47 @@ +import { difference, type EyebrowVentNode, type Ring, type RoofSegmentNode } from '@pascal-app/core' +import { getRoofOuterSurfaceFrameAtPoint } from '@pascal-app/viewer' +import * as THREE from 'three' +import { roofFootprintInsideTrim } from '../shared/roof-footprint-trim' +import { + getDownSlopeYaw, + getRoofSurfaceFaceBoundsAt, + surfaceQuatFromNormal, +} from '../shared/roof-surface' + +export function resolveEyebrowVentPlacement( + segment: RoofSegmentNode, + node: EyebrowVentNode, + x: number, + z: number, + options: { gridStep?: number; roofOffset?: number; alignDownslope?: boolean } = {}, +) { + const step = options.gridStep ?? 0 + if (step > 0) { + x = Math.round(x / step) * step + z = Math.round(z / step) * step + } + const frame = getRoofOuterSurfaceFrameAtPoint(segment, x, z) + const rotation = options.alignDownslope ? getDownSlopeYaw(x, z, segment) : node.rotation + const quat = surfaceQuatFromNormal(frame.normal, new THREE.Quaternion()).multiply( + new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), rotation), + ) + const hw = Math.max(0.15, node.width) / 2 + const hd = Math.max(0.15, node.depth) / 2 + const footprint: Ring = [ + [-hw, -hd], + [hw, -hd], + [hw, hd], + [-hw, hd], + ].map(([px, pz]) => { + const point = new THREE.Vector3(px, 0, pz).applyQuaternion(quat) + return [x + point.x, z + point.z] + }) + const face = getRoofSurfaceFaceBoundsAt(segment, x, z).polygon + return { + position: [x, frame.point.y + (options.roofOffset ?? 0), z] as [number, number, number], + rotation, + valid: + roofFootprintInsideTrim(segment, footprint) && + difference(footprint, face, { throwOnError: true }).length === 0, + } +} diff --git a/packages/nodes/src/eyebrow-vent/preview.tsx b/packages/nodes/src/eyebrow-vent/preview.tsx index d8f8008b43..0c97026ede 100644 --- a/packages/nodes/src/eyebrow-vent/preview.tsx +++ b/packages/nodes/src/eyebrow-vent/preview.tsx @@ -37,14 +37,9 @@ const EyebrowVentPreview = ({ node, invalid }: { node: EyebrowVentNode; invalid? const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry]) - useEffect( - () => () => { - geometry.dispose() - edgesGeometry.dispose() - material.dispose() - }, - [geometry, edgesGeometry, material], - ) + useEffect(() => () => geometry.dispose(), [geometry]) + useEffect(() => () => edgesGeometry.dispose(), [edgesGeometry]) + useEffect(() => () => material.dispose(), [material]) return ( @@ -56,7 +51,12 @@ const EyebrowVentPreview = ({ node, invalid }: { node: EyebrowVentNode; invalid? }} /> - + ) diff --git a/packages/nodes/src/eyebrow-vent/renderer.tsx b/packages/nodes/src/eyebrow-vent/renderer.tsx index fe9bfe2ef4..048a8892a6 100644 --- a/packages/nodes/src/eyebrow-vent/renderer.tsx +++ b/packages/nodes/src/eyebrow-vent/renderer.tsx @@ -13,13 +13,14 @@ import { createMaterial, createMaterialFromPresetRef, createSurfaceRoleMaterial, + getRoofOuterSurfaceFrameAtPoint, resolveMaterialRef, useNodeEvents, useViewer, } from '@pascal-app/viewer' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' -import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' +import { surfaceQuatFromNormal } from '../shared/roof-surface' import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip' import { buildEyebrowVentGeometry } from './geometry' @@ -67,7 +68,11 @@ const EyebrowVentRenderer = ({ node: storeNode }: { node: EyebrowVentNode }) => const surfaceQuat = useMemo(() => { if (!segment) return new THREE.Quaternion() - const normal = getAnalyticalNormal(node.position[0] ?? 0, node.position[2] ?? 0, segment) + const normal = getRoofOuterSurfaceFrameAtPoint( + segment, + node.position[0], + node.position[2], + ).normal return surfaceQuatFromNormal(normal, new THREE.Quaternion()) }, [segment, node.position[0], node.position[2]]) diff --git a/packages/nodes/src/eyebrow-vent/tool.test.tsx b/packages/nodes/src/eyebrow-vent/tool.test.tsx new file mode 100644 index 0000000000..95febd3142 --- /dev/null +++ b/packages/nodes/src/eyebrow-vent/tool.test.tsx @@ -0,0 +1,92 @@ +import { expect, test } from 'bun:test' +import { + BuildingNode, + emitter, + LevelNode, + nodeRegistry, + RoofNode, + RoofSegmentNode, + registerNode, + sceneRegistry, + useScene, +} from '@pascal-app/core' +import { useEditor, usePlacementPreview } from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { act, create } from '@react-three/test-renderer' +import * as THREE from 'three' +import { buildEyebrowVentGeometry, eyebrowVentDefinition } from './index' + +test('eyebrow-vent placement matches its preview, honors grid mode and blocks unsupported drops', async () => { + const restoreRegistry = nodeRegistry._snapshot() + if (!nodeRegistry.get('eyebrow-vent')) registerNode(eyebrowVentDefinition) + const savedEditor = useEditor.getState() + useEditor.setState({ mode: 'build', tool: 'eyebrow-vent', gridSnapStep: 0.25 }) + useEditor.getState().setSnappingMode('item', 'grid') + const savedScene = useScene.getState() + const savedSelection = useViewer.getState().selection + const building = BuildingNode.parse({}) + const level = LevelNode.parse({ parentId: building.id }) + const roof = RoofNode.parse({ parentId: level.id }) + const segment = RoofSegmentNode.parse({ parentId: roof.id, width: 20, depth: 20 }) + roof.children = [segment.id] + level.children = [roof.id] + building.children = [level.id] + useScene.setState({ + nodes: { [building.id]: building, [level.id]: level, [roof.id]: roof, [segment.id]: segment }, + rootNodeIds: [building.id], + dirtyNodes: new Set(), + readOnly: false, + }) + useViewer.getState().setSelection({ buildingId: building.id, levelId: level.id }) + sceneRegistry.nodes.set(segment.id, new THREE.Group()) + const { default: Tool } = await eyebrowVentDefinition.tool!() + const renderer = await create() + try { + const event = { + node: roof, + position: [0.13, 3, 3.13] as [number, number, number], + stopPropagation: () => {}, + } + await act(async () => emitter.emit('roof:move', event)) + const preview = usePlacementPreview.getState().node + expect(preview?.type).toBe('eyebrow-vent') + if (preview?.type !== 'eyebrow-vent') throw new Error('Missing eyebrow-vent preview') + await act(async () => emitter.emit('roof:click', event)) + const placed = Object.values(useScene.getState().nodes).find( + (node) => node.type === 'eyebrow-vent', + ) + expect(placed).toBeDefined() + if (placed?.type !== 'eyebrow-vent') throw new Error('Missing eyebrow vent') + expect(preview.position).toEqual(placed.position) + expect(placed.position[0]).toBe(0.25) + expect(placed.position[2]).toBe(3.25) + expect(preview.id).not.toBe(placed.id) + const previewGeometry = buildEyebrowVentGeometry(preview)! + const placedGeometry = buildEyebrowVentGeometry(placed)! + previewGeometry.computeBoundingBox() + placedGeometry.computeBoundingBox() + expect(previewGeometry.boundingBox!.equals(placedGeometry.boundingBox!)).toBe(true) + previewGeometry.dispose() + placedGeometry.dispose() + expect(usePlacementPreview.getState().node).toBeNull() + const outside = { ...event, position: [30, 3, 3] as [number, number, number] } + await act(async () => emitter.emit('roof:click', outside)) + expect( + Object.values(useScene.getState().nodes).filter((n) => n.type === 'eyebrow-vent'), + ).toHaveLength(1) + await act(async () => + emitter.emit('roof:click', { ...outside, nativeEvent: { altKey: true } as never }), + ) + expect( + Object.values(useScene.getState().nodes).filter((n) => n.type === 'eyebrow-vent'), + ).toHaveLength(2) + } finally { + restoreRegistry() + useEditor.setState(savedEditor) + await renderer.unmount() + sceneRegistry.nodes.delete(segment.id) + useScene.setState(savedScene) + useViewer.getState().setSelection(savedSelection) + usePlacementPreview.getState().clear() + } +}) diff --git a/packages/nodes/src/eyebrow-vent/tool.tsx b/packages/nodes/src/eyebrow-vent/tool.tsx index 303e481896..125d32ebb2 100644 --- a/packages/nodes/src/eyebrow-vent/tool.tsx +++ b/packages/nodes/src/eyebrow-vent/tool.tsx @@ -5,151 +5,165 @@ import { EyebrowVentNode, 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 { + isGridSnapActive, + isMagneticSnapActive, + triggerSFX, + useEditor, + usePlacementPreview, +} from '@pascal-app/editor' +import { getRoofOuterSurfaceFrameAtPoint, useViewer } from '@pascal-app/viewer' +import { useEffect, useMemo, useState } from 'react' import * as THREE from 'three' +import { roofSegmentLocalToBuildingLocal } from '../shared/relative-roof-drag' import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' -import { getAnalyticalNormal, getDownSlopeYaw, surfaceQuatFromNormal } from '../shared/roof-surface' +import { surfaceQuatFromNormal } from '../shared/roof-surface' import { clearRoofSurfacePlacementGuides, - publishRoofSurfacePlacementGuides, - roofSurfaceFootprintFromNode, + publishRoofSurfaceNodePlacementGuides, + snapRoofSurfaceNodeTarget, } from '../shared/roof-surface-placement-guides' import { eyebrowVentDefinition } from './definition' +import { resolveEyebrowVentPlacement } from './placement' import EyebrowVentPreview from './preview' -const worldPoint = new THREE.Vector3() - -/** - * Eyebrow-vent placement tool. Mounts when the palette activates the - * eyebrow-vent kind; listens for `roof:*` events; on click commits a new - * `EyebrowVentNode` parented to the targeted segment with segment-local - * coordinates. Mirrors box-vent / cupola. - */ -const EyebrowVentTool = () => { +export default function EyebrowVentTool() { 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(null) - const [previewYaw, setPreviewYaw] = useState(0) - const [previewRotation, setPreviewRotation] = useState(0) - const lastSnapRef = useRef<[number, number] | null>(null) - - const previewNode = useMemo( - () => - EyebrowVentNode.parse({ - ...eyebrowVentDefinition.defaults(), - name: 'Eyebrow Vent', - position: [0, 0, 0], - rotation: previewRotation, - }), - [previewRotation], + const seed = useMemo( + () => EyebrowVentNode.parse({ ...eyebrowVentDefinition.defaults(), name: 'Eyebrow Vent' }), + [], ) + const [preview, setPreview] = useState<{ + node: EyebrowVentNode + position: [number, number, number] + normal: THREE.Quaternion + yaw: number + valid: boolean + } | null>(null) 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 clear = () => { + setPreview(null) + if (usePlacementPreview.getState().node?.id === seed.id) + usePlacementPreview.getState().clear() + clearRoofSurfacePlacementGuides() } - - 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 (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== sx || prev[1] !== sz)) { - triggerSFX('sfx:grid-snap') - lastSnapRef.current = [sx, sz] + const resolve = (event: RoofEvent) => { + const hit = resolveRoofSegmentHit(event.node, ...event.position) + if (!hit) return null + const force = event.nativeEvent?.altKey === true + let placement = resolveEyebrowVentPlacement(hit.segment, seed, hit.localX, hit.localZ, { + gridStep: !force && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0, + alignDownslope: true, + }) + if (!force && isMagneticSnapActive()) { + const target = snapRoofSurfaceNodeTarget({ + target: { + ...hit, + hit, + localX: placement.position[0], + localY: placement.position[1], + localZ: placement.position[2], + }, + node: { ...seed, rotation: placement.rotation }, + }) + placement = resolveEyebrowVentPlacement(hit.segment, seed, target.localX, target.localZ, { + alignDownslope: true, + }) } - - 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)) - setPreviewRotation(getDownSlopeYaw(hit.localX, hit.localZ, hit.segment)) - setPreviewPos(worldToBuildingLocal(wx, wy, wz)) - publishRoofSurfacePlacementGuides({ - roof: event.node as RoofNode, + return { hit, placement, force } + } + const show = (event: RoofEvent) => { + const resolved = resolve(event) + if (!resolved) { + clear() + return + } + const { hit, placement, force } = resolved + const node = { + ...seed, + position: placement.position, + rotation: placement.rotation, + parentId: hit.segment.id, + roofSegmentId: hit.segment.id, + } + const normal = getRoofOuterSurfaceFrameAtPoint( + hit.segment, + placement.position[0], + placement.position[2], + ).normal + setPreview({ + node, + position: roofSegmentLocalToBuildingLocal(hit.segment.id, placement.position), + normal: surfaceQuatFromNormal(normal, new THREE.Quaternion()), + yaw: event.node.rotation + hit.segment.rotation, + valid: placement.valid || force, + }) + usePlacementPreview.getState().set(node, hit.segment) + publishRoofSurfaceNodePlacementGuides({ + roof: event.node, segment: hit.segment, - center: [hit.localX, hit.localY, hit.localZ], - footprint: roofSurfaceFootprintFromNode({ - ...previewNode, - rotation: getDownSlopeYaw(hit.localX, hit.localZ, hit.segment), - }), + center: placement.position, + node, }) 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 = EyebrowVentNode.parse({ - ...eyebrowVentDefinition.defaults(), - name: 'Eyebrow Vent', + const place = (event: RoofEvent) => { + const resolved = resolve(event) + if (!resolved) return + const { hit, placement, force } = resolved + if (!placement.valid && !force) { + show(event) + return + } + const node = EyebrowVentNode.parse({ + ...seed, + id: undefined, roofSegmentId: hit.segment.id, - position: [hit.localX, hit.localY, hit.localZ], - rotation: getDownSlopeYaw(hit.localX, hit.localZ, hit.segment), + position: placement.position, + rotation: placement.rotation, }) - state.createNode(vent, hit.segment.id as AnyNodeId) - state.dirtyNodes.add(hit.segment.id as AnyNodeId) - setSelection({ selectedIds: [vent.id] }) + useScene.getState().createNode(node, hit.segment.id as AnyNodeId) + useViewer.getState().setSelection({ selectedIds: [node.id] }) triggerSFX('sfx:item-place') - clearRoofSurfacePlacementGuides() + clear() event.stopPropagation() } - - emitter.on('roof:move', updatePreview) - emitter.on('roof:enter', updatePreview) - emitter.on('roof:click', onClick) - + emitter.on('roof:move', show) + emitter.on('roof:enter', show) + emitter.on('roof:leave', clear) + emitter.on('roof:click', place) + emitter.on('tool:cancel', clear) return () => { - emitter.off('roof:move', updatePreview) - emitter.off('roof:enter', updatePreview) - emitter.off('roof:click', onClick) - clearRoofSurfacePlacementGuides() + emitter.off('roof:move', show) + emitter.off('roof:enter', show) + emitter.off('roof:leave', clear) + emitter.off('roof:click', place) + emitter.off('tool:cancel', clear) + clear() } - }, [activeBuildingId, setSelection, previewNode]) + }, [activeBuildingId, seed]) return ( <> } + ghost={} onInvalidTarget={() => { - setPreviewPos(null) - setPreviewSurfaceQuat(null) + setPreview(null) + usePlacementPreview.getState().clear() clearRoofSurfacePlacementGuides() }} /> - {activeBuildingId && previewPos && previewSurfaceQuat && ( - - - - + {preview && ( + + + + @@ -157,5 +171,3 @@ const EyebrowVentTool = () => { ) } - -export default EyebrowVentTool diff --git a/packages/nodes/src/gutter/definition.ts b/packages/nodes/src/gutter/definition.ts index 734e9f954b..2d0f689eb4 100644 --- a/packages/nodes/src/gutter/definition.ts +++ b/packages/nodes/src/gutter/definition.ts @@ -189,6 +189,7 @@ export const gutterDefinition: NodeDefinition = { }, toolHints: [ { key: 'Left click', label: 'Place gutter on roof eave' }, + { key: 'Alt', label: 'Free along eave' }, { key: 'Esc', label: 'Cancel' }, ], diff --git a/packages/nodes/src/gutter/downspouts-panel.tsx b/packages/nodes/src/gutter/downspouts-panel.tsx index 1f02eaa549..cc557edde2 100644 --- a/packages/nodes/src/gutter/downspouts-panel.tsx +++ b/packages/nodes/src/gutter/downspouts-panel.tsx @@ -7,12 +7,12 @@ import { type GutterNode, generateId, type RoofSegmentNode, + resolveAutomaticDownspoutLength, useScene, } from '@pascal-app/core' import { ActionButton, ActionGroup, PanelSection, triggerSFX } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useShallow } from 'zustand/react/shallow' -import { computeEaveY } from './eave-snap' import { resolveGutterOutletById } from './outlet-lookup' const DEFAULT_OUTLET_DIAMETER = 0.07 @@ -94,20 +94,28 @@ export default function DownspoutsPanel() { { id: outletId, offset: nextOutletOffset(gutter), diameter: DEFAULT_OUTLET_DIAMETER }, ] const state = useScene.getState() - state.updateNode(gutter.id as AnyNodeId, { outlets }) - state.dirtyNodes.add(gutter.id as AnyNodeId) const outlet = resolveGutterOutletById({ ...gutter, outlets }, outletId) - const dropLength = Math.max(0.1, computeEaveY(segment) + (outlet?.y ?? -gutter.size)) + if (!outlet) return + const dropLength = resolveAutomaticDownspoutLength( + state.nodes, + segment, + gutter, + outlets.at(-1)!.offset, + ) const downspout = DownspoutNode.parse({ name: 'Downspout', gutterId: gutter.id, outletId, length: dropLength, + lengthMode: 'to-ground', diameter: (outlet?.bore ?? DEFAULT_OUTLET_DIAMETER / 2) * 2, }) - state.createNode(downspout, segmentId) + state.applyNodeChanges({ + update: [{ id: gutter.id, data: { outlets } }], + create: [{ node: downspout, parentId: segmentId }], + }) state.dirtyNodes.add(segmentId) setSelection({ selectedIds: [downspout.id] }) triggerSFX('sfx:item-place') diff --git a/packages/nodes/src/gutter/eave-snap.ts b/packages/nodes/src/gutter/eave-snap.ts index 4ca1306967..a1d8c11e4d 100644 --- a/packages/nodes/src/gutter/eave-snap.ts +++ b/packages/nodes/src/gutter/eave-snap.ts @@ -160,3 +160,34 @@ export function resolveEaveSnap( } } } + +/** Keep the complete run on the eave; optional grid only affects its along-eave axis. */ +export function resolveGutterPlacement( + segment: RoofSegmentNode, + localX: number, + localZ: number, + length: number, + gridStep = 0, + force = false, +): EaveSnap & { length: number } { + const snap = resolveEaveSnap(segment, localX, localZ) + const alongX = snap.side.endsWith('Z') + const overhang = getRoofShapeEaveOverhang(segment) + const halfSpan = Math.max( + 0.025, + (alongX ? segment.width : segment.depth) / 2 + overhang - EAVE_TUCK_INWARD, + ) + const fittedLength = force + ? Math.max(0.05, length) + : Math.min(Math.max(0.05, length), halfSpan * 2) + const limit = Math.max(0, halfSpan - fittedLength / 2) + const raw = alongX ? localX : localZ + const snapped = gridStep > 0 ? Math.round(raw / gridStep) * gridStep : raw + const center = force ? raw : Math.max(-limit, Math.min(limit, snapped)) + return { + ...snap, + eaveX: alongX ? center : snap.eaveX, + eaveZ: alongX ? snap.eaveZ : center, + length: fittedLength, + } +} diff --git a/packages/nodes/src/gutter/floorplan.ts b/packages/nodes/src/gutter/floorplan.ts index 56f2c9088e..622e3fbeb1 100644 --- a/packages/nodes/src/gutter/floorplan.ts +++ b/packages/nodes/src/gutter/floorplan.ts @@ -60,7 +60,9 @@ export function buildGutterFloorplan( ] const halfLen = Math.max(node.length, 0.1) / 2 - const width = Math.max(node.size, 0.05) // outward extent of the trough + const width = + Math.max(node.size, 0.04) * + (node.profile === 'half-round' ? 2 : node.profile === 'k-style' ? 0.95 : 1) // outward extent of the trough // The gutter's stored position is the eave drip edge — `halfD + overhang` // out from the segment centre (resolveEaveSnap). The roof floor plan diff --git a/packages/nodes/src/gutter/geometry.ts b/packages/nodes/src/gutter/geometry.ts index 906df1631f..e658825629 100644 --- a/packages/nodes/src/gutter/geometry.ts +++ b/packages/nodes/src/gutter/geometry.ts @@ -434,7 +434,9 @@ function buildKStyleCross(size: number, t: number): THREE.Shape { shape.lineTo(wBot, -size) shape.bezierCurveTo(wBot + size * 0.15, ogeeY, wTop - size * 0.15, ogeeY * 0.4, wTop, 0) // Front rim (thin top of the front wall): step inward by `t`. - shape.lineTo(wTop - t, 0) + shape.lineTo(wTop - Math.max(t, size * 0.09), 0) + shape.lineTo(wTop - Math.max(t, size * 0.09), -t) + shape.lineTo(wTop - t, -t) // Inner trace — back down the ogee, across the inner bottom, up the // inner back wall. Same bezier control points pushed inward by `t`. shape.bezierCurveTo( @@ -472,7 +474,9 @@ function buildHalfRoundCross(size: number, t: number): THREE.Shape { shape.lineTo(r + r * Math.cos(angle), r * Math.sin(angle)) } // Front rim — step inward by `t` to start the inner trace. - shape.lineTo(2 * r - t, 0) + const bead = Math.max(t / 2, size * 0.025) + shape.absarc(2 * r - bead, 0, bead, 0, Math.PI, false) + shape.lineTo(2 * r - t, -t) // Inner semicircle, traced BACK toward the back wall (angle 2π → π). for (let i = 1; i <= segs; i++) { const angle = 2 * Math.PI - (Math.PI * i) / segs @@ -495,7 +499,9 @@ function buildBoxCross(size: number, t: number): THREE.Shape { shape.lineTo(w, -size) shape.lineTo(w, 0) // Front rim. - shape.lineTo(w - t, 0) + shape.lineTo(w - Math.max(t, size * 0.09), 0) + shape.lineTo(w - Math.max(t, size * 0.09), -t) + shape.lineTo(w - t, -t) // Inner rect, reversed so the polygon doesn't self-intersect. shape.lineTo(w - t, -size + t) shape.lineTo(t, -size + t) @@ -605,7 +611,7 @@ function buildHangers( // Span the usable run with straps at `spacing` between centers, plus // one at each end. Symmetric layout for any length, including very // short gutters where two straps land at the bounds. - const count = Math.max(1, Math.floor(usable / spacing) + 1) + const count = Math.max(1, Math.ceil(usable / spacing) + 1) const stride = count > 1 ? usable / (count - 1) : 0 const pieces: THREE.BufferGeometry[] = [] diff --git a/packages/nodes/src/gutter/index.ts b/packages/nodes/src/gutter/index.ts index cf870cf3d5..9aceefb643 100644 --- a/packages/nodes/src/gutter/index.ts +++ b/packages/nodes/src/gutter/index.ts @@ -1,3 +1,4 @@ export { gutterDefinition } from './definition' +export { resolveGutterPlacement } from './eave-snap' export { buildGutterGeometry } from './geometry' export { GutterNode } from './schema' diff --git a/packages/nodes/src/gutter/move-tool.tsx b/packages/nodes/src/gutter/move-tool.tsx index 78c104cc28..0ef0ee5cfc 100644 --- a/packages/nodes/src/gutter/move-tool.tsx +++ b/packages/nodes/src/gutter/move-tool.tsx @@ -12,28 +12,29 @@ import { } from '@pascal-app/core' import { consumePlacementDragRelease, + isGridSnapActive, markToolCancelConsumed, triggerSFX, useEditor, } from '@pascal-app/editor' import { useCallback, useEffect, useState } from 'react' -import { createRelativeRoofDrag, snapRelativeRoofDragTarget } from '../shared/relative-roof-drag' +import { createRelativeRoofDrag } from '../shared/relative-roof-drag' import { clearRoofSurfacePlacementGuides, publishRoofSurfaceNodePlacementGuides, } from '../shared/roof-surface-placement-guides' -import { type EaveSnap, resolveEaveSnap } from './eave-snap' +import { type EaveSnap, resolveGutterPlacement } from './eave-snap' import GutterPreview from './preview' type PreviewTarget = { roof: { position: [number, number, number]; rotation: number } segment: { position: [number, number, number]; rotation: number } - snap: EaveSnap + snap: EaveSnap & { length: number } } type GutterDragTarget = { segment: RoofSegmentNode - snap: EaveSnap + snap: EaveSnap & { length: number } } /** @@ -60,8 +61,6 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) { const [target, setTarget] = useState(null) useEffect(() => { - useScene.temporal.getState().pause() - const original = { position: [...node.position] as [number, number, number], rotation: node.rotation ?? 0, @@ -93,10 +92,17 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) { const resolveTarget = (event: RoofEvent): GutterDragTarget | null => { const rawTarget = roofDrag.resolve(event) if (!rawTarget) return null - const target = snapRelativeRoofDragTarget(rawTarget, event.nativeEvent?.shiftKey === true) + const target = rawTarget return { segment: target.segment, - snap: resolveEaveSnap(target.segment, target.localX, target.localZ), + snap: resolveGutterPlacement( + target.segment, + target.localX, + target.localZ, + node.length, + !event.nativeEvent?.altKey && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0, + event.nativeEvent?.altKey === true, + ), } } @@ -116,10 +122,11 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) { // stay on ±Z. const { snap } = target - const sx = Math.round(snap.eaveX * 20) / 20 - const sz = Math.round(snap.eaveZ * 20) / 20 + const sx = snap.eaveX + const sz = snap.eaveZ if ( - event.nativeEvent?.shiftKey !== true && + !event.nativeEvent?.altKey && + isGridSnapActive() && (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) ) { triggerSFX('sfx:grid-snap') @@ -141,7 +148,7 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) { roof, segment: target.segment, center: [snap.eaveX, snap.eaveY, snap.eaveZ], - node: { ...node, rotation: snap.rotation }, + node: { ...node, length: snap.length, rotation: snap.rotation }, mode: 'linear-edge', }) event.stopPropagation() @@ -156,33 +163,15 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) { const { snap } = target 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: [snap.eaveX, snap.eaveY, snap.eaveZ], rotation: snap.rotation, visible: true, - metadata: {}, + length: snap.length, + metadata: { ...meta, isNew: undefined }, }) - useScene.temporal.getState().pause() st.dirtyNodes.add(targetSegmentId) st.dirtyNodes.add(node.id as AnyNodeId) @@ -198,37 +187,16 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) { 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() clearRoofSurfacePlacementGuides() 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() clearRoofSurfacePlacementGuides() exitMoveMode() @@ -261,7 +229,6 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) { const obj = sceneRegistry.nodes.get(node.id) if (obj) obj.visible = true clearRoofSurfacePlacementGuides() - useScene.temporal.getState().resume() } }, [exitMoveMode, node]) @@ -274,7 +241,7 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) { position={[target.snap.eaveX, target.snap.eaveY, target.snap.eaveZ]} rotation-y={target.snap.rotation} > - + diff --git a/packages/nodes/src/gutter/panel.tsx b/packages/nodes/src/gutter/panel.tsx new file mode 100644 index 0000000000..8f0394f2af --- /dev/null +++ b/packages/nodes/src/gutter/panel.tsx @@ -0,0 +1,133 @@ +'use client' + +import { type GutterNode, useLiveNodeOverrides, useScene } from '@pascal-app/core' +import { + ActionButton, + ActionGroup, + duplicateNodeAndPickUp, + PanelSection, + PanelWrapper, + SliderControl, + useEditor, +} from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { Copy, Move, Trash2 } from 'lucide-react' +import DownspoutsPanel from './downspouts-panel' + +export default function GutterPanel({ node: stored }: { node: GutterNode }) { + const overrides = useLiveNodeOverrides((s) => s.get(stored.id)) + const node = { ...stored, ...overrides } as GutterNode + const setSelection = useViewer((s) => s.setSelection) + const commit = (patch: Partial) => { + useScene.getState().updateNode(node.id, patch) + useLiveNodeOverrides.getState().clear(node.id) + } + const dimension = ( + key: 'length' | 'size' | 'thickness' | 'hangerSpacing', + label: string, + min: number, + step: number, + ) => ( + useLiveNodeOverrides.getState().set(node.id, { [key]: value })} + onCommit={(value) => commit({ [key]: value })} + /> + ) + return ( + setSelection({ selectedIds: [] })} + > + +
+ {(['k-style', 'half-round', 'box'] as const).map((profile) => ( + + ))} +
+
+ + {dimension('length', 'Run length', 0.05, 0.05)} + {dimension('size', 'Channel depth', 0.04, 0.005)} + {dimension('thickness', 'Wall thickness', 0.001, 0.001)} +

+ Half-round width is twice the channel depth. Move aligns the run to the roof eave. +

+
+ + {(['endCapLeft', 'endCapRight'] as const).map((key) => ( + + ))} +

+ Caps are hidden where runs form a mitred corner. +

+
+ + + {node.hangerStyle !== 'none' && dimension('hangerSpacing', 'Maximum spacing', 0.2, 0.05)} + + + + + } + label="Move" + onClick={() => { + useEditor.getState().setMovingNode(stored) + setSelection({ selectedIds: [] }) + }} + /> + } + label="Duplicate" + onClick={() => duplicateNodeAndPickUp(stored)} + /> + } + label="Delete" + onClick={() => { + useScene.getState().deleteNode(node.id) + setSelection({ selectedIds: [] }) + }} + /> + + +
+ ) +} diff --git a/packages/nodes/src/gutter/parametrics.ts b/packages/nodes/src/gutter/parametrics.ts index 622d390ccb..97d49c29cd 100644 --- a/packages/nodes/src/gutter/parametrics.ts +++ b/packages/nodes/src/gutter/parametrics.ts @@ -2,6 +2,7 @@ import type { ParametricDescriptor } from '@pascal-app/core' import type { GutterNode } from './schema' export const gutterParametrics: ParametricDescriptor = { + customPanel: () => import('./panel'), groups: [ { label: 'Profile', @@ -17,14 +18,13 @@ export const gutterParametrics: ParametricDescriptor = { { label: 'Dimensions', fields: [ - { key: 'length', kind: 'number', unit: 'm', min: 0.2, max: 1000, step: 0.05 }, - { key: 'size', kind: 'number', unit: 'm', min: 0.05, max: 0.3, step: 0.005 }, + { key: 'length', kind: 'number', unit: 'm', min: 0.2, step: 0.05 }, + { key: 'size', kind: 'number', unit: 'm', min: 0.05, step: 0.005 }, { key: 'thickness', kind: 'number', unit: 'm', min: 0.001, - max: 0.02, step: 0.001, }, ], @@ -50,7 +50,6 @@ export const gutterParametrics: ParametricDescriptor = { kind: 'number', unit: 'm', min: 0.2, - max: 2.0, step: 0.05, visibleIf: (n) => (n.hangerStyle ?? 'strap') !== 'none', }, diff --git a/packages/nodes/src/gutter/placement.test.ts b/packages/nodes/src/gutter/placement.test.ts new file mode 100644 index 0000000000..a54073ed16 --- /dev/null +++ b/packages/nodes/src/gutter/placement.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from 'bun:test' +import { RoofSegmentNode } from '@pascal-app/core' +import { resolveGutterPlacement } from './index' + +describe('gutter placement', () => { + const segment = RoofSegmentNode.parse({ type: 'roof-segment', width: 6, depth: 4, overhang: 0.3 }) + test('keeps both endpoints within the eave', () => { + const pose = resolveGutterPlacement(segment, 3, 2, 2) + expect(pose.eaveX + pose.length / 2).toBeLessThanOrEqual(3.3) + expect(pose.eaveZ).toBeGreaterThan(2) + }) + test('fits an oversized run instead of crossing both corners', () => { + const pose = resolveGutterPlacement(segment, 2, 2, 20) + expect(pose.eaveX).toBe(0) + expect(pose.length).toBeLessThanOrEqual(6.6) + }) + test('grid affects only the along-eave axis and off keeps raw precision', () => { + const raw = resolveGutterPlacement(segment, 0.37, 2, 2) + const grid = resolveGutterPlacement(segment, 0.37, 2, 2, 0.25) + expect(raw.eaveX).toBe(0.37) + expect(grid.eaveX).toBe(0.25) + expect(grid.eaveZ).toBe(raw.eaveZ) + expect(grid.eaveY).toBe(raw.eaveY) + }) + test('force preserves the raw center and length beyond the corner', () => { + const pose = resolveGutterPlacement(segment, 3.37, 2, 20, 0.25, true) + expect(pose.eaveX).toBe(3.37) + expect(pose.length).toBe(20) + }) + test('side eaves constrain the swapped axis', () => { + const pose = resolveGutterPlacement({ ...segment, roofType: 'hip' }, 3, 1.9, 2) + expect(pose.side).toBe('+X') + expect(pose.eaveZ + pose.length / 2).toBeLessThanOrEqual(2.3) + }) +}) diff --git a/packages/nodes/src/gutter/tool.test.tsx b/packages/nodes/src/gutter/tool.test.tsx new file mode 100644 index 0000000000..358042864f --- /dev/null +++ b/packages/nodes/src/gutter/tool.test.tsx @@ -0,0 +1,91 @@ +import { expect, test } from 'bun:test' +import { + BuildingNode, + emitter, + LevelNode, + nodeRegistry, + RoofNode, + RoofSegmentNode, + registerNode, + sceneRegistry, + useScene, +} from '@pascal-app/core' +import { getMovingNode, useEditor, usePlacementPreview } from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { act, create } from '@react-three/test-renderer' +import * as THREE from 'three' +import { gutterDefinition } from './index' + +test('gutter placement commits a grid-snapped gutter and cancels a move without changing the scene', async () => { + const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window') + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: Object.assign(new EventTarget(), { + requestAnimationFrame: () => 0, + cancelAnimationFrame: () => {}, + }), + }) + const restoreRegistry = nodeRegistry._snapshot() + if (!nodeRegistry.get('gutter')) registerNode(gutterDefinition) + const savedEditor = useEditor.getState() + useEditor.setState({ mode: 'build', tool: 'gutter', gridSnapStep: 0.25 }) + useEditor.getState().setSnappingMode('item', 'grid') + const savedScene = useScene.getState() + const savedSelection = useViewer.getState().selection + const building = BuildingNode.parse({}) + const level = LevelNode.parse({ parentId: building.id }) + const roof = RoofNode.parse({ parentId: level.id }) + const segment = RoofSegmentNode.parse({ parentId: roof.id, width: 20, depth: 20 }) + roof.children = [segment.id] + level.children = [roof.id] + building.children = [level.id] + useScene.setState({ + nodes: { [building.id]: building, [level.id]: level, [roof.id]: roof, [segment.id]: segment }, + rootNodeIds: [building.id], + dirtyNodes: new Set(), + readOnly: false, + }) + useViewer.getState().setSelection({ buildingId: building.id, levelId: level.id }) + sceneRegistry.nodes.set(segment.id, new THREE.Group()) + const { default: Tool } = await gutterDefinition.tool!() + let renderer = await create() + try { + const event = { + node: roof, + position: [0.13, 3, 3.13] as [number, number, number], + stopPropagation: () => {}, + } + await act(async () => emitter.emit('roof:move', event)) + await act(async () => emitter.emit('roof:click', event)) + const placed = Object.values(useScene.getState().nodes).find((node) => node.type === 'gutter') + expect(placed).toBeDefined() + if (placed?.type !== 'gutter') throw new Error('Missing gutter') + expect(placed.position[0]).toBe(0.25) + expect(placed.position[2]).toBeGreaterThan(10) + expect(useScene.getState().nodes[segment.id]?.children).toContain(placed.id) + await renderer.unmount() + const object = new THREE.Group() + sceneRegistry.nodes.set(placed.id, object) + useEditor.getState().setMovingNode(placed) + const { default: Move } = await gutterDefinition.affordanceTools!.move!() + renderer = await create() + expect(object.visible).toBe(false) + await act(async () => emitter.emit('tool:cancel')) + expect(getMovingNode()).toBeNull() + await renderer.unmount() + expect(object.visible).toBe(true) + expect(useScene.getState().nodes[placed.id]).toEqual(placed) + sceneRegistry.nodes.delete(placed.id) + } finally { + restoreRegistry() + useEditor.setState(savedEditor) + await renderer.unmount() + if (originalWindow) Object.defineProperty(globalThis, 'window', originalWindow) + else Reflect.deleteProperty(globalThis, 'window') + sceneRegistry.nodes.delete(segment.id) + sceneRegistry.nodes.delete(building.id) + useScene.setState(savedScene) + useViewer.getState().setSelection(savedSelection) + usePlacementPreview.getState().clear() + } +}) diff --git a/packages/nodes/src/gutter/tool.tsx b/packages/nodes/src/gutter/tool.tsx index c4e08b5423..2b9596f828 100644 --- a/packages/nodes/src/gutter/tool.tsx +++ b/packages/nodes/src/gutter/tool.tsx @@ -8,7 +8,7 @@ import { type RoofNode, useScene, } from '@pascal-app/core' -import { triggerSFX } from '@pascal-app/editor' +import { isGridSnapActive, triggerSFX, useEditor } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview' @@ -19,13 +19,13 @@ import { roofSurfaceFootprintFromNode, } from '../shared/roof-surface-placement-guides' import { gutterDefinition } from './definition' -import { type EaveSnap, resolveEaveSnap } from './eave-snap' +import { type EaveSnap, resolveGutterPlacement } from './eave-snap' import GutterPreview from './preview' type PreviewTarget = { roof: { position: [number, number, number]; rotation: number } segment: { position: [number, number, number]; rotation: number } - snap: EaveSnap + snap: EaveSnap & { length: number } } /** @@ -81,15 +81,26 @@ const GutterTool = () => { ) if (!hit) return - const snap = resolveEaveSnap(hit.segment, hit.localX, hit.localZ) + const snap = resolveGutterPlacement( + hit.segment, + hit.localX, + hit.localZ, + previewNode.length, + !event.nativeEvent?.altKey && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0, + event.nativeEvent?.altKey === true, + ) // Grid-snap chime fires when the segment-local snap moves to a // new 5 cm cell along the eave — keeps SFX in lockstep with what // the commit will actually store. - const sx = Math.round(snap.eaveX * 20) / 20 - const sz = Math.round(snap.eaveZ * 20) / 20 + const sx = snap.eaveX + const sz = snap.eaveZ const prev = lastSnapRef.current - if (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== sx || prev[1] !== sz)) { + if ( + !event.nativeEvent?.altKey && + isGridSnapActive() && + (!prev || prev[0] !== sx || prev[1] !== sz) + ) { triggerSFX('sfx:grid-snap') lastSnapRef.current = [sx, sz] } @@ -109,7 +120,11 @@ const GutterTool = () => { roof, segment: hit.segment, center: [snap.eaveX, snap.eaveY, snap.eaveZ], - footprint: roofSurfaceFootprintFromNode({ ...previewNode, rotation: snap.rotation }), + footprint: roofSurfaceFootprintFromNode({ + ...previewNode, + length: snap.length, + rotation: snap.rotation, + }), mode: 'linear-edge', }) event.stopPropagation() @@ -124,7 +139,14 @@ const GutterTool = () => { ) if (!hit) return const state = useScene.getState() - const snap = resolveEaveSnap(hit.segment, hit.localX, hit.localZ) + const snap = resolveGutterPlacement( + hit.segment, + hit.localX, + hit.localZ, + previewNode.length, + !event.nativeEvent?.altKey && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0, + event.nativeEvent?.altKey === true, + ) const gutter = GutterNode.parse({ ...gutterDefinition.defaults(), @@ -136,6 +158,7 @@ const GutterTool = () => { // building on whichever side the click landed. position: [snap.eaveX, snap.eaveY, snap.eaveZ], rotation: snap.rotation, + length: snap.length, }) state.createNode(gutter, hit.segment.id as AnyNodeId) state.dirtyNodes.add(hit.segment.id as AnyNodeId) @@ -145,11 +168,19 @@ const GutterTool = () => { event.stopPropagation() } + const clearTarget = () => { + setTarget(null) + clearRoofSurfacePlacementGuides() + } + emitter.on('roof:leave', clearTarget) + emitter.on('tool:cancel', clearTarget) emitter.on('roof:move', updatePreview) emitter.on('roof:enter', updatePreview) emitter.on('roof:click', onClick) return () => { + emitter.off('roof:leave', clearTarget) + emitter.off('tool:cancel', clearTarget) emitter.off('roof:move', updatePreview) emitter.off('roof:enter', updatePreview) emitter.off('roof:click', onClick) @@ -174,7 +205,7 @@ const GutterTool = () => { position={[target.snap.eaveX, target.snap.eaveY, target.snap.eaveZ]} rotation-y={target.snap.rotation} > - +
diff --git a/packages/nodes/src/hvac-equipment/definition.ts b/packages/nodes/src/hvac-equipment/definition.ts index db61cdff29..07acbd0f69 100644 --- a/packages/nodes/src/hvac-equipment/definition.ts +++ b/packages/nodes/src/hvac-equipment/definition.ts @@ -1,6 +1,7 @@ import type { NodeDefinition } from '@pascal-app/core' import { buildHvacEquipmentFloorplan } from './floorplan' import { buildHvacEquipmentGeometry } from './geometry' +import { hvacEquipmentToolOptions } from './options' import { hvacEquipmentParametrics } from './parametrics' import { getHvacEquipmentPorts } from './ports' import { HvacEquipmentNode } from './schema' @@ -33,6 +34,11 @@ export const hvacEquipmentDefinition: NodeDefinition = width: 0.56, depth: 0.71, height: 1.1, + supplyFace: 'top', + returnFace: 'left', + serviceFace: 'right', + suctionDiameter: 0.875, + liquidDiameter: 0.375, supplyShape: 'round', returnShape: 'round', supplyDiameter: 8, @@ -61,11 +67,17 @@ export const hvacEquipmentDefinition: NodeDefinition = }, parametrics: hvacEquipmentParametrics, + toolOptions: hvacEquipmentToolOptions, geometry: buildHvacEquipmentGeometry, geometryKey: (n) => JSON.stringify([ n.equipmentType, + n.supplyFace, + n.returnFace, + n.serviceFace, + n.suctionDiameter, + n.liquidDiameter, n.width, n.depth, n.height, @@ -101,6 +113,6 @@ export const hvacEquipmentDefinition: NodeDefinition = mcp: { description: - 'HVAC equipment cabinet (furnace, air handler, or condenser). Furnaces and air handlers have supply/return duct ports; every unit also has a refrigerant service port that a lineset run connects to. Position is level-local meters; rotation is yaw radians.', + 'HVAC equipment cabinet (furnace, air handler, or condenser). Furnaces and air handlers have supply/return duct ports; every unit has separate suction (lineset) and liquid ports, with configurable sizes and cabinet sides. Position is level-local meters; rotation is yaw radians.', }, } diff --git a/packages/nodes/src/hvac-equipment/geometry.ts b/packages/nodes/src/hvac-equipment/geometry.ts index 72fb612256..2a1ab0234a 100644 --- a/packages/nodes/src/hvac-equipment/geometry.ts +++ b/packages/nodes/src/hvac-equipment/geometry.ts @@ -29,7 +29,6 @@ const EQUIPMENT_WHITE = '#eef0f2' const EQUIPMENT_TRIM = '#cfd3d8' const CABINET_COLOR = EQUIPMENT_WHITE -const INTERIOR_COLOR = '#9aa1a8' const PANEL_COLOR = EQUIPMENT_TRIM const CONTROL_COLOR = '#3f4549' const CONDENSER_COLOR = EQUIPMENT_WHITE @@ -41,7 +40,6 @@ const BLOWER_BLADE_COLOR = '#274f7d' const BURNER_COLOR = '#d9772e' const GAS_PIPE_COLOR = '#d2691e' const AIR_HANDLER_COLOR = EQUIPMENT_WHITE -const AIR_HANDLER_TRIM = EQUIPMENT_TRIM const FAN_GRILLE_COLOR = '#3a3f44' const FAN_BLADE_COLOR = '#d7dade' const COIL_FIN_COLOR = '#9aa1a8' @@ -86,11 +84,6 @@ export function buildHvacEquipmentGeometry(node: HvacEquipmentNode): Group { metalness: 0.55, roughness: 0.45, }) - const interior = new MeshStandardMaterial({ - color: INTERIOR_COLOR, - metalness: 0.4, - roughness: 0.6, - }) const addBox = ( w: number, @@ -109,40 +102,8 @@ export function buildHvacEquipmentGeometry(node: HvacEquipmentNode): Group { return mesh } - const ports = localEquipmentPorts(node) - const supplyPort = ports.find((p) => p.id === 'supply') - const returnPort = ports.find((p) => p.id === 'return') - - // ── Cabinet shell as butt-jointed sheet-metal plates. Top + bottom span - // the full footprint; the four walls sit *between* them (height innerH), - // and back / front pieces sit *between* the side walls (width W - 2t). No - // two same-facing surfaces are ever coplanar, which is what was z-fighting - // when these were full-size overlapping boxes; single-sided materials - // (above) finish the job. Left wall carries the return hole, top the supply. - const innerH = H - 2 * t - const midY = H / 2 const frontZ = hd - t / 2 - - addBox(W, t, D, cabinet, 0, t / 2, 0, 'equipment-bottom') - addBox(t, innerH, D, interior, hw - t / 2, midY, 0, 'equipment-right') - addBox(W - 2 * t, innerH, t, interior, 0, midY, -hd + t / 2, 'equipment-back') - - // Top plate, flat, with the supply hole at the cabinet center. Built - // centered in its own XY plane (x→W, y→D); rotate.x = -90° lays it flat. - const top = buildHolePlate(W, D, t, supplyPort, 0, 0, cabinet) - top.name = 'equipment-top' - top.rotation.x = -Math.PI / 2 - top.position.set(0, H - t / 2, 0) - group.add(top) - - // Left wall with the return hole. After rotate.y = -90° the plate's x→world - // -z and y→world height; centered at midY with the return port at world - // y = H*0.35, so the hole sits at plate-y (H*0.35 - midY). - const left = buildHolePlate(D, innerH, t, returnPort, 0, H * 0.35 - midY, interior) - left.name = 'equipment-left' - left.rotation.y = -Math.PI / 2 - left.position.set(-hw + t / 2, midY, 0) - group.add(left) + buildCabinetShell(node, group, cabinet, false) // Front opening: framed sill, jambs and an upper control panel, all inset // to (W - 2t) so they tuck between the side walls. The gap between sill @@ -452,9 +413,7 @@ function buildHolePlate( width: number, height: number, thickness: number, - port: LocalPort | undefined, - hx: number, - hy: number, + holes: { port: LocalPort; x: number; y: number }[], material: MeshStandardMaterial, ): Mesh { const hw = width / 2 @@ -466,8 +425,16 @@ function buildHolePlate( .lineTo(-hw, hh) .lineTo(-hw, -hh) - const hole = port ? collarHolePath(collarSection(port), hx, hy, hw * 0.95, hh * 0.95) : null - if (hole) shape.holes.push(hole) + for (const { port, x, y } of holes) { + const hole = collarHolePath( + collarSection(port), + x, + y, + Math.max(0, hw - Math.abs(x)) * 0.95, + Math.max(0, hh - Math.abs(y)) * 0.95, + ) + if (hole) shape.holes.push(hole) + } const geom = new ExtrudeGeometry(shape, { depth: thickness, bevelEnabled: false }) geom.translate(0, 0, -thickness / 2) @@ -475,6 +442,53 @@ function buildHolePlate( return new Mesh(geom, material) } +function buildCabinetShell( + node: HvacEquipmentNode, + group: Group, + material: MeshStandardMaterial, + front: boolean, +) { + const { width: w, height: h, depth: d } = node + const t = Math.min(0.02, w * 0.04, d * 0.04) + const ports = localEquipmentPorts(node) + const top = buildHolePlate( + w, + d, + t, + ports + .filter((port) => port.direction.y > 0) + .map((port) => ({ port, x: port.position.x, y: -port.position.z })), + material, + ) + top.name = 'equipment-top' + top.rotation.x = -Math.PI / 2 + top.position.y = h - t / 2 + group.add(top) + for (const side of [-1, 1]) { + const wall = buildHolePlate( + d, + h - 2 * t, + t, + ports + .filter((port) => port.direction.x === side) + .map((port) => ({ port, x: -side * port.position.z, y: port.position.y - h / 2 })), + material, + ) + wall.name = side < 0 ? 'equipment-left' : 'equipment-right' + wall.rotation.y = (side * Math.PI) / 2 + wall.position.set(side * (w / 2 - t / 2), h / 2, 0) + group.add(wall) + } + const bottom = new Mesh(new BoxGeometry(w, t, d), material) + bottom.position.y = t / 2 + group.add(bottom) + for (const side of front ? [-1, 1] : [-1]) { + const wall = new Mesh(new BoxGeometry(w - 2 * t, h - 2 * t, t), material) + wall.position.set(0, h / 2, side * (d / 2 - t / 2)) + group.add(wall) + } +} + /** * Sheet-metal sleeves at the supply/return ports. Each collar straddles the * wall hole — part inside the cabinet, part outside — so a duct run slides @@ -503,27 +517,7 @@ function buildCollars(node: HvacEquipmentNode, group: Group): void { } } -// Default lineset line radii (meters) — must mirror the lineset kind's -// defaults so the two service stubs sit exactly where its suction/liquid -// pipes run. See `lineset/geometry.ts` (suction 7/8", liquid 3/8", 3/8" -// foam jacket) and its symmetric ±offset about the path centerline. -const LINESET_SUCTION_R = (0.875 * INCHES_TO_METERS) / 2 -const LINESET_LIQUID_R = (0.375 * INCHES_TO_METERS) / 2 -const LINESET_JACKET_R = LINESET_SUCTION_R + 0.01 -const LINESET_PAIR_OFFSET = LINESET_JACKET_R + LINESET_LIQUID_R - -/** - * Refrigerant service valves at the lineset port — a brass-grey valve body - * with two copper stubs the lineset run mates onto. Built on every - * equipment type so a split system can be piped from condenser to coil. - * - * A lineset is a parallel pair (insulated suction + bare liquid) offset - * symmetrically about its path centerline. The snap point is that - * centerline, so a single stub would sit in the empty gap between the two - * pipes. Instead we emit two stubs at exactly the lineset's ±offset along - * the port's horizontal perpendicular: the suction pipe lands on the wide - * stub, the liquid pipe on the narrow one, when the run leaves the face. - */ +/** Sleeves wrap the outgoing lines at the cabinet face, preserving legacy port positions. */ function buildServiceValves(node: HvacEquipmentNode, group: Group): void { const valveMat = new MeshStandardMaterial({ color: SERVICE_VALVE_COLOR, @@ -537,35 +531,20 @@ function buildServiceValves(node: HvacEquipmentNode, group: Group): void { }) for (const port of localRefrigerantPorts(node)) { const dir = port.direction.clone().normalize() - // Horizontal perpendicular to the port — matches the lineset geometry's - // `horizontal.cross(UP)`, so the stub offsets track its pipe offsets. - const perp = dir.clone().cross(UP).normalize() - - // Brass-grey valve body bolted to the cabinet face, spanning the pair. - const bodyWidth = 2 * LINESET_PAIR_OFFSET + 2 * LINESET_JACKET_R - const body = new Mesh(new BoxGeometry(0.05, 0.08, bodyWidth), valveMat) - body.name = 'service-valve-body' + const radius = (port.diameter * INCHES_TO_METERS) / 2 + const body = new Mesh(new BoxGeometry(0.05, 0.06, Math.max(0.04, radius * 2.5)), valveMat) + body.name = `service-valve-body-${port.id}` body.position.copy(port.position).addScaledVector(dir, 0.025) - body.quaternion.setFromUnitVectors(UP, dir) group.add(body) - const stubLen = 0.07 - const addStub = (sign: number, radius: number, id: string) => { - const stub = new Mesh( - new CylinderGeometry(radius, radius, stubLen, SMALL_SEGMENTS), - copperMat, - ) - stub.name = `service-valve-stub-${id}` - stub.position - .copy(port.position) - .addScaledVector(perp, sign * LINESET_PAIR_OFFSET) - .addScaledVector(dir, 0.05 + stubLen / 2) - stub.quaternion.setFromUnitVectors(UP, dir) - group.add(stub) - } - // Suction pipe is the lineset's -offset line; liquid is +offset. - addStub(-1, LINESET_SUCTION_R, 'suction') - addStub(1, LINESET_LIQUID_R, 'liquid') + const stub = new Mesh( + new CylinderGeometry(radius + 0.002, radius + 0.002, stubLen, SMALL_SEGMENTS, 1, true), + copperMat, + ) + stub.name = `service-valve-stub-${port.id}` + stub.position.copy(port.position).addScaledVector(dir, stubLen / 2) + stub.quaternion.setFromUnitVectors(UP, dir) + group.add(stub) } } @@ -776,7 +755,7 @@ function buildAxialFan( // Concentric wire grille (rings) over the front of the fan. const ringMat = new MeshStandardMaterial({ - color: AIR_HANDLER_TRIM, + color: EQUIPMENT_TRIM, metalness: 0.5, roughness: 0.4, }) @@ -807,35 +786,14 @@ function buildAirHandler(node: HvacEquipmentNode, group: Group): Group { metalness: 0.3, roughness: 0.55, }) - const trimMat = new MeshStandardMaterial({ - color: AIR_HANDLER_TRIM, - metalness: 0.4, - roughness: 0.5, - }) const finMat = new MeshStandardMaterial({ color: COIL_FIN_COLOR, metalness: 0.6, roughness: 0.45, }) - // Cabinet body + top/bottom trim caps. - const body = new Mesh(new BoxGeometry(W, H, D), cabinetMat) - body.name = 'equipment-body' - body.position.set(0, H / 2, 0) - group.add(body) - // Trim caps straddle the cabinet's top / bottom edges (centered on - // y = H and y = 0) so the body's end faces fall inside the cap volume. - // Sitting them flush instead (top face at y = H) leaves two coplanar - // full-footprint faces that z-fight. + buildCabinetShell(node, group, cabinetMat, true) const capH = Math.min(0.05, H * 0.06) - const topCap = new Mesh(new BoxGeometry(W * 1.04, capH, D * 1.04), trimMat) - topCap.name = 'air-handler-top-cap' - topCap.position.set(0, H, 0) - group.add(topCap) - const botCap = new Mesh(new BoxGeometry(W * 1.04, capH, D * 1.04), trimMat) - botCap.name = 'air-handler-bottom-cap' - botCap.position.set(0, 0, 0) - group.add(botCap) // Two stacked axial fans on the front face, sized to the cabinet width. const frontZ = hd + 0.001 diff --git a/packages/nodes/src/hvac-equipment/hvac-equipment.test.ts b/packages/nodes/src/hvac-equipment/hvac-equipment.test.ts new file mode 100644 index 0000000000..3eba02a660 --- /dev/null +++ b/packages/nodes/src/hvac-equipment/hvac-equipment.test.ts @@ -0,0 +1,102 @@ +import { expect, test } from 'bun:test' +import { HvacEquipmentNode, LinesetNode, LiquidLineNode, portConnection } from '@pascal-app/core' +import { useEditor } from '@pascal-app/editor' +import { Box3, type Group, Vector3 } from 'three' +import { hvacEquipmentDefinition, linesetDefinition, liquidLineDefinition } from '../index' + +const equipment = HvacEquipmentNode.parse({}) + +test('legacy equipment preserves its original collars and gains a separate liquid port', () => { + const ports = hvacEquipmentDefinition.ports!(equipment) + expect(ports.find((p) => p.id === 'supply')!.position).toEqual([0, equipment.height, 0]) + expect(ports.find((p) => p.id === 'return')!.position).toEqual([ + -equipment.width / 2, + equipment.height * 0.35, + 0, + ]) + const suction = ports.find((p) => p.id === 'lineset')! + expect(suction.position).toEqual([equipment.width / 2, equipment.height * 0.8, 0]) + expect(suction.diameter).toBe(0.875) + const liquid = ports.find((p) => p.id === 'liquid')! + expect(liquid.position).not.toEqual(suction.position) + expect(liquid.connectionType).toBe('liquid') + expect(liquid.diameter).toBe(0.375) +}) + +test('configurable faces and rotation place rendered collars at the advertised ports', () => { + for (const equipmentType of ['furnace', 'air-handler', 'condenser'] as const) { + const node = HvacEquipmentNode.parse({ + equipmentType, + supplyFace: 'left', + returnFace: 'right', + serviceFace: 'left', + suctionDiameter: 1.125, + liquidDiameter: 0.5, + }) + const ports = hvacEquipmentDefinition.ports!(node) + const geometry = hvacEquipmentDefinition.geometry!(node, {} as never) as Group + geometry.updateMatrixWorld(true) + for (const port of ports.filter((p) => p.system !== 'refrigerant')) { + const collar = geometry.getObjectByName(`equipment-collar-${port.id}`)! + expect(collar).toBeDefined() + expect(new Box3().setFromObject(collar).containsPoint(new Vector3(...port.position))).toBe( + true, + ) + } + for (const port of ports.filter((p) => p.system === 'refrigerant')) { + const stub = geometry.getObjectByName(`service-valve-stub-${port.id}`)! + const tip = new Vector3(0, -0.035, 0).applyMatrix4(stub.matrixWorld) + expect(tip.distanceTo(new Vector3(...port.position))).toBeLessThan(0.0001) + } + const rotated = hvacEquipmentDefinition.ports!({ + ...node, + position: [4, 1, 2], + rotation: Math.PI / 2, + }) + const suction = rotated.find((p) => p.id === 'lineset')! + expect(suction.direction[2]).toBeCloseTo(1) + expect(suction.position[0]).toBeCloseTo(4) + expect(suction.position[2]).toBeCloseTo(2 + node.width / 2) + } +}) + +test('separate suction and liquid routes mate only to their own service connections', () => { + const ports = hvacEquipmentDefinition.ports!(equipment) + for (const id of ['lineset', 'liquid']) { + const port = ports.find((p) => p.id === id)! + const path = [port.position, [port.position[0] + 1, port.position[1], port.position[2]]] + const run = + id === 'lineset' + ? LinesetNode.parse({ path, liquidDiameter: 0.75 }) + : LiquidLineNode.parse({ path }) + const start = ( + id === 'lineset' + ? linesetDefinition.ports!(run as LinesetNode) + : liquidLineDefinition.ports!(run as LiquidLineNode) + )[0]! + expect(portConnection(port, start).status).toBe('match') + expect( + portConnection({ ...port, connectionType: id === 'lineset' ? 'liquid' : 'suction' }, start) + .status, + ).toBe('incompatible') + } +}) + +test('equipment choices apply valid named cabinet presets before placement', () => { + const previous = useEditor.getState().toolDefaults + try { + const option = hvacEquipmentDefinition.toolOptions![0]! + for (const value of ['furnace', 'air-handler', 'condenser']) { + option.set(value) + const configured = HvacEquipmentNode.parse({ + ...hvacEquipmentDefinition.defaults(), + ...useEditor.getState().toolDefaults['hvac-equipment'], + }) + expect(configured.equipmentType).toBe(value) + expect(configured.name).toBeDefined() + expect(option.value()).toBe(value) + } + } finally { + useEditor.setState({ toolDefaults: previous }) + } +}) diff --git a/packages/nodes/src/hvac-equipment/options.ts b/packages/nodes/src/hvac-equipment/options.ts new file mode 100644 index 0000000000..74a1cb8dd6 --- /dev/null +++ b/packages/nodes/src/hvac-equipment/options.ts @@ -0,0 +1,27 @@ +import type { ToolOption } from '@pascal-app/core' +import { useEditor } from '@pascal-app/editor' +import { HVAC_PRESETS } from './presets' + +export const hvacEquipmentToolOptions: ToolOption[] = [ + { + id: 'equipmentType', + label: 'Equipment', + choices: Object.entries(HVAC_PRESETS).map(([value, preset]) => ({ + value, + label: preset.name, + description: 'Generic cabinet dimensions; edit the placed unit to match your equipment.', + })), + subscribe: (callback) => useEditor.subscribe(callback), + value: () => + String(useEditor.getState().toolDefaults['hvac-equipment']?.equipmentType ?? 'furnace'), + set: (value) => { + if (!(value in HVAC_PRESETS)) return + const equipmentType = value as keyof typeof HVAC_PRESETS + useEditor.getState().setToolDefaults('hvac-equipment', { + ...useEditor.getState().toolDefaults['hvac-equipment'], + ...HVAC_PRESETS[equipmentType], + equipmentType, + }) + }, + }, +] diff --git a/packages/nodes/src/hvac-equipment/parametrics.ts b/packages/nodes/src/hvac-equipment/parametrics.ts index 9130f9ed0a..95572df2fb 100644 --- a/packages/nodes/src/hvac-equipment/parametrics.ts +++ b/packages/nodes/src/hvac-equipment/parametrics.ts @@ -3,6 +3,14 @@ import type { HvacEquipmentNode } from './schema' export const hvacEquipmentParametrics: ParametricDescriptor = { groups: [ + { + label: 'Refrigerant connections', + fields: [ + { key: 'serviceFace', kind: 'enum', options: ['left', 'right'], display: 'segmented' }, + { key: 'suctionDiameter', kind: 'number', unit: 'in', min: 0.25, max: 2, step: 0.125 }, + { key: 'liquidDiameter', kind: 'number', unit: 'in', min: 0.125, max: 1, step: 0.125 }, + ], + }, { label: 'Equipment', fields: [ @@ -17,14 +25,21 @@ export const hvacEquipmentParametrics: ParametricDescriptor = { label: 'Cabinet', fields: [ - { key: 'width', kind: 'number', unit: 'm', min: 0.3, max: 1000, step: 0.05 }, - { key: 'depth', kind: 'number', unit: 'm', min: 0.3, max: 1000, step: 0.05 }, - { key: 'height', kind: 'number', unit: 'm', min: 0.4, max: 1000, step: 0.05 }, + { key: 'width', kind: 'number', unit: 'm', min: 0.3, max: 2, step: 0.05 }, + { key: 'depth', kind: 'number', unit: 'm', min: 0.3, max: 2, step: 0.05 }, + { key: 'height', kind: 'number', unit: 'm', min: 0.4, max: 2.5, step: 0.05 }, ], }, { label: 'Supply', fields: [ + { + key: 'supplyFace', + kind: 'enum', + options: ['top', 'left', 'right'], + display: 'segmented', + visibleIf: (n) => n.equipmentType !== 'condenser', + }, { key: 'supplyShape', kind: 'enum', @@ -64,6 +79,13 @@ export const hvacEquipmentParametrics: ParametricDescriptor = { label: 'Return', fields: [ + { + key: 'returnFace', + kind: 'enum', + options: ['left', 'right'], + display: 'segmented', + visibleIf: (n) => n.equipmentType !== 'condenser', + }, { key: 'returnShape', kind: 'enum', diff --git a/packages/nodes/src/hvac-equipment/ports.ts b/packages/nodes/src/hvac-equipment/ports.ts index 9bcfb21c29..35de8be20a 100644 --- a/packages/nodes/src/hvac-equipment/ports.ts +++ b/packages/nodes/src/hvac-equipment/ports.ts @@ -13,8 +13,9 @@ type LocalPort = { system: 'supply' | 'return' | 'refrigerant' // Duct collars only — the cross-section the collar mesh and wall hole // take. `diameter` above is the area-equivalent round size the port - // advertises so round runs mate at a sensible size. Refrigerant ports + // advertises for sizing summaries; physical mating also checks the shape. Refrigerant ports // are always round and omit these. + connectionType?: string shape?: CollarShape width?: number height?: number @@ -27,11 +28,6 @@ function collarDiameterIn(shape: CollarShape, diameter: number, width: number, h return diameter } -/** Nominal suction-line OD (inches) the refrigerant service connection - * advertises — matches the lineset kind's default suction diameter so a - * lineset run mates cleanly onto the valve. */ -const REFRIGERANT_PORT_DIAMETER_IN = 0.875 - /** * Duct ports in the cabinet's LOCAL frame (origin at the base center, * before yaw / position). Matches a typical upflow furnace / vertical air @@ -44,8 +40,18 @@ export function localEquipmentPorts(node: HvacEquipmentNode): LocalPort[] { return [ { id: 'supply', - position: new Vector3(0, node.height, 0), - direction: new Vector3(0, 1, 0), + position: + node.supplyFace === 'left' || node.supplyFace === 'right' + ? new Vector3( + ((node.supplyFace === 'left' ? -1 : 1) * node.width) / 2, + node.height * 0.8, + 0, + ) + : new Vector3(0, node.height, 0), + direction: + node.supplyFace === 'left' || node.supplyFace === 'right' + ? new Vector3(node.supplyFace === 'left' ? -1 : 1, 0, 0) + : new Vector3(0, 1, 0), diameter: collarDiameterIn( node.supplyShape, node.supplyDiameter, @@ -59,8 +65,12 @@ export function localEquipmentPorts(node: HvacEquipmentNode): LocalPort[] { }, { id: 'return', - position: new Vector3(-node.width / 2, node.height * 0.35, 0), - direction: new Vector3(-1, 0, 0), + position: new Vector3( + ((node.returnFace === 'right' ? 1 : -1) * node.width) / 2, + node.height * 0.35, + 0, + ), + direction: new Vector3(node.returnFace === 'right' ? 1 : -1, 0, 0), diameter: collarDiameterIn( node.returnShape, node.returnDiameter, @@ -78,20 +88,32 @@ export function localEquipmentPorts(node: HvacEquipmentNode): LocalPort[] { /** * Refrigerant service connection in the cabinet's LOCAL frame — the point * a lineset run leaves from (condenser) or arrives at (indoor coil on a - * furnace / air handler). Every equipment type exposes exactly one, on the + * furnace / air handler). Every equipment type exposes separate suction and liquid ports, on the * +X service-valve face: a condenser/air-handler near the bottom third, a * furnace near the top where the cased A-coil sits above the heat * exchanger. */ export function localRefrigerantPorts(node: HvacEquipmentNode): LocalPort[] { const y = node.equipmentType === 'furnace' ? node.height * 0.8 : node.height * 0.3 + const side = node.serviceFace === 'left' ? -1 : 1 + const spacing = + (((node.suctionDiameter ?? 0.875) + (node.liquidDiameter ?? 0.375)) * 0.0254) / 2 + 0.03 return [ { id: 'lineset', - position: new Vector3(node.width / 2, y, 0), - direction: new Vector3(1, 0, 0), - diameter: REFRIGERANT_PORT_DIAMETER_IN, + position: new Vector3((side * node.width) / 2, y, 0), + direction: new Vector3(side, 0, 0), + diameter: node.suctionDiameter ?? 0.875, + system: 'refrigerant', + connectionType: 'suction', + }, + { + id: 'liquid', + position: new Vector3((side * node.width) / 2, y, spacing), + direction: new Vector3(side, 0, 0), + diameter: node.liquidDiameter ?? 0.375, system: 'refrigerant', + connectionType: 'liquid', }, ] } @@ -114,6 +136,7 @@ export function getHvacEquipmentPorts(node: HvacEquipmentNode): NodePort[] { direction: [direction.x, direction.y, direction.z] as const, diameter: port.diameter, system: port.system, + connectionType: port.connectionType, shape: port.shape, width: port.width, height: port.height, diff --git a/packages/nodes/src/hvac-equipment/presets.ts b/packages/nodes/src/hvac-equipment/presets.ts new file mode 100644 index 0000000000..cd8c33d037 --- /dev/null +++ b/packages/nodes/src/hvac-equipment/presets.ts @@ -0,0 +1,10 @@ +import type { HvacEquipmentNode } from './schema' + +export const HVAC_PRESETS = { + furnace: { name: 'Furnace', width: 0.56, depth: 0.71, height: 1.1 }, + 'air-handler': { name: 'Air handler', width: 0.6, depth: 0.65, height: 1.5 }, + condenser: { name: 'Condenser', width: 0.8, depth: 0.8, height: 0.9 }, +} satisfies Record< + HvacEquipmentNode['equipmentType'], + { name: string; width: number; depth: number; height: number } +> diff --git a/packages/nodes/src/hvac-equipment/tool.tsx b/packages/nodes/src/hvac-equipment/tool.tsx index 6a87740320..a98498335a 100644 --- a/packages/nodes/src/hvac-equipment/tool.tsx +++ b/packages/nodes/src/hvac-equipment/tool.tsx @@ -22,6 +22,7 @@ import { subscribeAccessorySnapping } from '../shared/accessory-snapping' import { LevelOffsetGroup } from '../shared/level-offset-group' import { hvacEquipmentDefinition } from './definition' import { buildHvacEquipmentGeometry } from './geometry' +import { HVAC_PRESETS } from './presets' const PREVIEW_OPACITY = 0.55 /** R/T yaw step — 45°, matching the editor's default rotate. */ @@ -52,7 +53,9 @@ const HvacEquipmentTool = () => { HvacEquipmentNode.parse({ ...hvacEquipmentDefinition.defaults(), ...toolDefaults, - name: 'Furnace', + name: HVAC_PRESETS[ + (toolDefaults?.equipmentType as HvacEquipmentNode['equipmentType']) ?? 'furnace' + ].name, }), [toolDefaults], ) @@ -94,10 +97,13 @@ const HvacEquipmentTool = () => { const onClick = (event: GridEvent) => { const position = resolveAligned(event) + const placementDefaults = useEditor.getState().toolDefaults['hvac-equipment'] const unit = HvacEquipmentNode.parse({ ...hvacEquipmentDefinition.defaults(), - ...toolDefaults, - name: 'Furnace', + ...placementDefaults, + name: HVAC_PRESETS[ + (placementDefaults?.equipmentType as HvacEquipmentNode['equipmentType']) ?? 'furnace' + ].name, position, rotation: yawRef.current, parentId: activeLevelId, diff --git a/packages/nodes/src/index.ts b/packages/nodes/src/index.ts index 85bbe690a8..922ebcf000 100644 --- a/packages/nodes/src/index.ts +++ b/packages/nodes/src/index.ts @@ -182,7 +182,11 @@ export { dormerDefinition } from './dormer' export { downspoutDefinition } from './downspout' export { ductFittingDefinition } from './duct-fitting' export { ductSegmentDefinition } from './duct-segment' -export { ductTerminalDefinition } from './duct-terminal' +export { + ductTerminalDefinition, + resolveTerminalPortPlacement, + terminalProfileForPort, +} from './duct-terminal' export { elevatorDefinition } from './elevator' export { eyebrowVentDefinition } from './eyebrow-vent' export { beginFenceFeaturePlacement, fenceDefinition } from './fence' diff --git a/packages/nodes/src/lean-to-extension/floorplan-tool.tsx b/packages/nodes/src/lean-to-extension/floorplan-tool.tsx index 4835ca81bd..34c0fd5407 100644 --- a/packages/nodes/src/lean-to-extension/floorplan-tool.tsx +++ b/packages/nodes/src/lean-to-extension/floorplan-tool.tsx @@ -23,7 +23,6 @@ import { import { leanToFacetCount } from './geometry' import { resolveLeanToSpanArc } from './layout' import { - LEAN_TO_RUN_CONNECT_SNAP_RADIUS, LEAN_TO_RUN_MAGNETIC_SNAP_RADIUS, type LeanToPlanPlacementTarget, nextLeanToCanopyForm, @@ -118,19 +117,18 @@ const FloorplanLeanToExtensionTool = ({ if (chainStartRef.current && isContinuous()) { const proposedEnd = snappedEventPoint(event) if (!proposedEnd) return null - const snap = event.altKey - ? null - : resolveLeanToFreestandingRunEndpointSnap({ - activeLevelId, - canopyForm: formRef.current, - flipProjection: chainFlipRef.current, - maxDistance: isMagneticSnapActive() - ? LEAN_TO_RUN_MAGNETIC_SNAP_RADIUS - : LEAN_TO_RUN_CONNECT_SNAP_RADIUS, - nodes, - proposedEnd, - start: chainStartRef.current, - }) + const snap = + event.altKey || !isMagneticSnapActive() + ? null + : resolveLeanToFreestandingRunEndpointSnap({ + activeLevelId, + canopyForm: formRef.current, + flipProjection: chainFlipRef.current, + maxDistance: LEAN_TO_RUN_MAGNETIC_SNAP_RADIUS, + nodes, + proposedEnd, + start: chainStartRef.current, + }) const snapKey = snap ? `${snap.nodeId}:${snap.side}` : null if (snapKey && snapKey !== lastRunSnapKey) triggerSFX('sfx:grid-snap') lastRunSnapKey = snapKey @@ -228,6 +226,13 @@ const FloorplanLeanToExtensionTool = ({ } } const onKeyDown = (event: KeyboardEvent) => { + if ( + event.target instanceof HTMLInputElement || + event.target instanceof HTMLTextAreaElement || + (event.target instanceof HTMLElement && event.target.isContentEditable) + ) { + return + } if (event.key === 'Escape') { event.preventDefault() event.stopImmediatePropagation() @@ -236,13 +241,6 @@ const FloorplanLeanToExtensionTool = ({ else finishTool() return } - if ( - event.target instanceof HTMLInputElement || - event.target instanceof HTMLTextAreaElement || - (event.target instanceof HTMLElement && event.target.isContentEditable) - ) { - return - } if ( chainStartRef.current && (event.key === 'r' || event.key === 'R' || event.key === 't' || event.key === 'T') diff --git a/packages/nodes/src/lean-to-extension/geometry.test.ts b/packages/nodes/src/lean-to-extension/geometry.test.ts index 74e7d31cfc..125cd27b5a 100644 --- a/packages/nodes/src/lean-to-extension/geometry.test.ts +++ b/packages/nodes/src/lean-to-extension/geometry.test.ts @@ -485,3 +485,26 @@ describe('lean-to extension geometry', () => { expect(group.getObjectByName('lean-to-opposite-rafter-0')?.rotation.x).toBeGreaterThan(0) }) }) + +test('freestanding mono knee braces connect both post rows to their beams', async () => { + const { + buildLeanToExtensionGeometry: build, + resolveLeanToFreestandingPlacement, + resolveLeanToLayout: layoutFor, + } = await import('./index') + const node = resolveLeanToFreestandingPlacement('level_test', [0, 0]) + expect(node.postBracing).toBe('knee') + expect(LeanToExtensionNode.parse({}).postBracing).toBe('none') + const layout = layoutFor(node) + const group = build(node) + const low = group.children.filter((child) => child.name.startsWith('lean-to-knee-brace-')) + const high = group.children.filter((child) => child.name.startsWith('lean-to-high-knee-brace-')) + expect(low).toHaveLength(layout.postXs.length) + expect(high).toHaveLength(layout.postXs.length) + for (const mesh of low) { + const bounds = new Box3().setFromObject(mesh) + expect(bounds.max.y).toBeGreaterThanOrEqual(layout.beamCenterY) + expect(bounds.min.y).toBeLessThan(layout.beamCenterY - 0.3) + expect(mesh.position.z).toBeCloseTo(layout.beamZ) + } +}) diff --git a/packages/nodes/src/lean-to-extension/geometry.ts b/packages/nodes/src/lean-to-extension/geometry.ts index 981d4c4cf6..b0f22b6868 100644 --- a/packages/nodes/src/lean-to-extension/geometry.ts +++ b/packages/nodes/src/lean-to-extension/geometry.ts @@ -634,31 +634,45 @@ export function buildLeanToExtensionGeometry( } if (node.postBracing === 'knee') { - for (const [index, x] of layout.postXs.entries()) { - if (!isRetainedLowPostX(x)) continue - addBentBox({ - name: `lean-to-knee-brace-${index}`, - size: [node.rafterWidth, node.rafterHeight, Math.min(0.8, layout.projection / 2)], - localX: x, - localZ: Math.max(0, layout.beamZ - 0.22), - y: layout.beamCenterY - 0.22, - rotationX: Math.PI / 4, + const reach = Math.min(0.55, layout.span / 4, layout.postHeight / 3) + const addBrace = (name: string, x: number, z: number, inward: number, top: number) => { + const start = bend(x, z) + const end = bend(x + inward * reach, z) + addBoxBetween(group, { + name, + start: [start[0], top - reach, start[1]], + end: [end[0], top, end[1]], + width: node.rafterWidth, + height: node.rafterHeight, role: 'joinery', + colorPreset, + sceneTheme, material: framingMaterial, slotId: 'framing', }) + } + for (const [index, x] of layout.postXs.entries()) { + if (!isRetainedLowPostX(x)) continue + addBrace(`lean-to-knee-brace-${index}`, x, layout.beamZ, x <= 0 ? 1 : -1, layout.beamCenterY) if (dualSlope) { - addBentBox({ - name: `lean-to-opposite-knee-brace-${index}`, - size: [node.rafterWidth, node.rafterHeight, Math.min(0.8, layout.projection / 2)], - localX: x, - localZ: Math.min(0, layout.oppositeBeamZ + 0.22), - y: layout.beamCenterY - 0.22, - rotationX: -Math.PI / 4, - role: 'joinery', - material: framingMaterial, - slotId: 'framing', - }) + addBrace( + `lean-to-opposite-knee-brace-${index}`, + x, + layout.oppositeBeamZ, + x <= 0 ? 1 : -1, + layout.beamCenterY, + ) + } else if (node.highSideMode === 'independent-high-beam') { + addBrace( + `lean-to-high-knee-brace-${index}`, + x, + 0, + x <= 0 ? 1 : -1, + layout.highEdgeHeight - + node.roofThickness / 2 - + node.ledgerHeight / 2 + + node.ledgerVerticalOffset, + ) } } } diff --git a/packages/nodes/src/lean-to-extension/layout.ts b/packages/nodes/src/lean-to-extension/layout.ts index 71ec5f616f..e4ee430149 100644 --- a/packages/nodes/src/lean-to-extension/layout.ts +++ b/packages/nodes/src/lean-to-extension/layout.ts @@ -17,7 +17,7 @@ import { isClosedLoopLeanTo } from './conical-host' export const MIN_LEAN_TO_POST_HEIGHT = 0.2 export const MIN_LEAN_TO_WALL_LENGTH = 0.6 -export const LEAN_TO_EXTENSION_GEOMETRY_REVISION = 8 +export const LEAN_TO_EXTENSION_GEOMETRY_REVISION = 9 export const LEAN_TO_EDGE_SNAP_TOLERANCE = 0.25 export const LEAN_TO_HEIGHT_SNAP_TOLERANCE = 0.15 const CURVED_INNER_EDGE_CLEARANCE = 0.15 diff --git a/packages/nodes/src/lean-to-extension/parametrics.ts b/packages/nodes/src/lean-to-extension/parametrics.ts index f0824b6e93..3f3c6da375 100644 --- a/packages/nodes/src/lean-to-extension/parametrics.ts +++ b/packages/nodes/src/lean-to-extension/parametrics.ts @@ -1,5 +1,6 @@ import type { LeanToExtensionNode, ParametricDescriptor } from '@pascal-app/core' import { leanToLowEdgeHeight, MIN_LEAN_TO_POST_HEIGHT, resolveLeanToLayout } from './layout' +import { CanopyStyleEditor } from './style-editor' const degrees = (rise: number, run: number) => Math.max(1, Math.min(45, (Math.atan2(rise, Math.max(0.001, run)) * 180) / Math.PI)) @@ -88,17 +89,13 @@ export const leanToExtensionParametrics: ParametricDescriptor node.hostKind === 'freestanding', - }, { key: 'autoSpan', label: 'Match host width', @@ -111,7 +108,7 @@ export const leanToExtensionParametrics: ParametricDescriptor node.connectionMode === 'manual' || !node.hostRoofSegmentId, }, @@ -189,6 +186,18 @@ export const leanToExtensionParametrics: ParametricDescriptor node.connectionMode === 'manual' || !node.hostRoofSegmentId, }, @@ -533,18 +542,6 @@ export const leanToExtensionParametrics: ParametricDescriptor node.framingStrategy === 'purlins' || node.framingStrategy === 'covering-specific', }, - { - key: 'postBracing', - label: 'Post bracing', - kind: 'enum', - options: ['none', 'knee'], - }, - { - key: 'footingStyle', - label: 'Footings', - kind: 'enum', - options: ['none', 'base-plate', 'concrete-pad'], - }, ], }, ], diff --git a/packages/nodes/src/lean-to-extension/placement.ts b/packages/nodes/src/lean-to-extension/placement.ts index 4ae3267100..741c9f56f0 100644 --- a/packages/nodes/src/lean-to-extension/placement.ts +++ b/packages/nodes/src/lean-to-extension/placement.ts @@ -144,6 +144,7 @@ export function resolveLeanToFreestandingPlacement( parentId: levelId, canopyForm, hostKind: 'freestanding', + postBracing: 'knee', highSideMode: 'independent-high-beam', connectionMode: 'manual', autoSpan: false, diff --git a/packages/nodes/src/lean-to-extension/style-editor.tsx b/packages/nodes/src/lean-to-extension/style-editor.tsx new file mode 100644 index 0000000000..e18831a1df --- /dev/null +++ b/packages/nodes/src/lean-to-extension/style-editor.tsx @@ -0,0 +1,40 @@ +'use client' + +import type { LeanToExtensionNode } from '@pascal-app/core' + +export function CanopyStyleEditor({ + node, + onUpdate, +}: { + node: LeanToExtensionNode + onUpdate: (patch: Partial) => void +}) { + return ( +
+
+ {(['mono', 'gable', 'butterfly'] as const).map((canopyForm) => ( + + ))} +
+

+ {node.hostKind === 'freestanding' + ? 'Mono slopes to one side; gable drains outward; butterfly drains to the centre.' + : 'Attached canopies use a mono roof. Place on open ground for all three forms.'} +

+
+ ) +} diff --git a/packages/nodes/src/lean-to-extension/tool.tsx b/packages/nodes/src/lean-to-extension/tool.tsx index d7d0df6f7b..650c7533a2 100644 --- a/packages/nodes/src/lean-to-extension/tool.tsx +++ b/packages/nodes/src/lean-to-extension/tool.tsx @@ -36,7 +36,6 @@ import { leanToExtensionGeometryKey } from './geometry' import { leanToWallLocalPose, resolveLeanToWallSurfaceHit } from './layout' import { findLeanToSlabEdgePlacement, - LEAN_TO_RUN_CONNECT_SNAP_RADIUS, LEAN_TO_RUN_MAGNETIC_SNAP_RADIUS, type LeanToPlanPlacementTarget, nextLeanToCanopyForm, @@ -196,19 +195,18 @@ const LeanToExtensionTool = () => { const updateContinuousTarget = (point: [number, number], altKey = false) => { if (!chainStart) return null const nodes = sceneApi.nodes() as Record - const snap = altKey - ? null - : resolveLeanToFreestandingRunEndpointSnap({ - activeLevelId, - canopyForm: freestandingCanopyForm, - flipProjection: chainFlipProjection, - maxDistance: isMagneticSnapActive() - ? LEAN_TO_RUN_MAGNETIC_SNAP_RADIUS - : LEAN_TO_RUN_CONNECT_SNAP_RADIUS, - nodes, - proposedEnd: point, - start: chainStart, - }) + const snap = + altKey || !isMagneticSnapActive() + ? null + : resolveLeanToFreestandingRunEndpointSnap({ + activeLevelId, + canopyForm: freestandingCanopyForm, + flipProjection: chainFlipProjection, + maxDistance: LEAN_TO_RUN_MAGNETIC_SNAP_RADIUS, + nodes, + proposedEnd: point, + start: chainStart, + }) const end = snap?.point ?? point setChainCursorPreview(end) const target = resolveLeanToFreestandingRunTarget({ diff --git a/packages/nodes/src/lineset/definition.ts b/packages/nodes/src/lineset/definition.ts index aa6808bf0a..df35fd2492 100644 --- a/packages/nodes/src/lineset/definition.ts +++ b/packages/nodes/src/lineset/definition.ts @@ -51,7 +51,7 @@ export const linesetDefinition: NodeDefinition = { parametrics: linesetParametrics, geometry: buildLinesetGeometry, - geometryKey: (n) => JSON.stringify([n.path, n.suctionDiameter, n.liquidDiameter, n.insulated]), + geometryKey: (n) => JSON.stringify([n.path, n.suctionDiameter, n.insulated]), // Open run ends as typed refrigerant ports — directions point outward // along the path tangent so they mate flush onto a service valve. Path @@ -78,6 +78,7 @@ export const linesetDefinition: NodeDefinition = { direction: unit(first, second), diameter, system: 'refrigerant', + connectionType: 'suction', }, { id: 'end', @@ -85,6 +86,7 @@ export const linesetDefinition: NodeDefinition = { direction: unit(last, prev), diameter, system: 'refrigerant', + connectionType: 'suction', }, ] }, @@ -121,9 +123,9 @@ export const linesetDefinition: NodeDefinition = { ], presentation: { - label: 'Lineset', + label: 'Suction line', description: - 'Refrigerant lineset — copper suction + liquid pair joining a condenser to the indoor coil.', + 'Refrigerant suction line joining a condenser to the indoor coil. Draw its liquid line separately.', icon: { kind: 'url', src: '/icons/lineset.webp' }, paletteSection: 'structure', paletteOrder: 93, @@ -131,6 +133,6 @@ export const linesetDefinition: NodeDefinition = { mcp: { description: - 'A refrigerant lineset defined as a polyline: an insulated suction line plus a bare liquid line, joining an HVAC condenser to its indoor coil. Snaps onto refrigerant service ports.', + 'A refrigerant lineset defined as a polyline: one suction line with optional insulation, joining an HVAC condenser to its indoor coil. Create a liquid-line node for the separate liquid route. Snaps onto refrigerant service ports.', }, } diff --git a/packages/nodes/src/lineset/parametrics.ts b/packages/nodes/src/lineset/parametrics.ts index d55eb78435..783da845b3 100644 --- a/packages/nodes/src/lineset/parametrics.ts +++ b/packages/nodes/src/lineset/parametrics.ts @@ -11,15 +11,7 @@ export const linesetParametrics: ParametricDescriptor = { kind: 'number', unit: 'in', min: 0.25, - max: 1.5, - step: 0.125, - }, - { - key: 'liquidDiameter', - kind: 'number', - unit: 'in', - min: 0.125, - max: 0.75, + max: 2, step: 0.125, }, ], diff --git a/packages/nodes/src/lineset/tool.tsx b/packages/nodes/src/lineset/tool.tsx index 7fd94555d4..c8f98c548e 100644 --- a/packages/nodes/src/lineset/tool.tsx +++ b/packages/nodes/src/lineset/tool.tsx @@ -71,7 +71,10 @@ function snap(value: number, step: number): number { function findNearbyPort(point: [number, number, number]): [number, number, number] | null { const port = findNearestPortXZ( point, - collectScenePorts({ systems: REFRIGERANT_PORT_SYSTEMS }), + collectScenePorts({ + systems: REFRIGERANT_PORT_SYSTEMS, + levelId: useViewer.getState().selection.levelId ?? undefined, + }).filter((port) => !port.connectionType || port.connectionType === 'suction'), ENDPOINT_SNAP_RADIUS_M, ) return port ? [port.position[0], port.position[1], port.position[2]] : null diff --git a/packages/nodes/src/liquid-line/definition.ts b/packages/nodes/src/liquid-line/definition.ts index b109174f8f..7272575f3d 100644 --- a/packages/nodes/src/liquid-line/definition.ts +++ b/packages/nodes/src/liquid-line/definition.ts @@ -77,6 +77,7 @@ export const liquidLineDefinition: NodeDefinition = { direction: unit(first, second), diameter, system: 'refrigerant', + connectionType: 'liquid', }, { id: 'end', @@ -84,6 +85,7 @@ export const liquidLineDefinition: NodeDefinition = { direction: unit(last, prev), diameter, system: 'refrigerant', + connectionType: 'liquid', }, ] }, diff --git a/packages/nodes/src/liquid-line/tool.tsx b/packages/nodes/src/liquid-line/tool.tsx index fd69e5704d..9f0c3d6659 100644 --- a/packages/nodes/src/liquid-line/tool.tsx +++ b/packages/nodes/src/liquid-line/tool.tsx @@ -90,7 +90,10 @@ function snap(value: number, step: number): number { function findNearbyPort(point: Vec3): Vec3 | null { const port = findNearestPortXZ( point, - collectScenePorts({ systems: REFRIGERANT_PORT_SYSTEMS }), + collectScenePorts({ + systems: REFRIGERANT_PORT_SYSTEMS, + levelId: useViewer.getState().selection.levelId ?? undefined, + }).filter((port) => !port.connectionType || port.connectionType === 'liquid'), ENDPOINT_SNAP_RADIUS_M, ) return port ? [port.position[0], port.position[1], port.position[2]] : null diff --git a/packages/nodes/src/ridge-vent/__tests__/geometry.test.ts b/packages/nodes/src/ridge-vent/__tests__/geometry.test.ts index 3a2e992576..622a7ff484 100644 --- a/packages/nodes/src/ridge-vent/__tests__/geometry.test.ts +++ b/packages/nodes/src/ridge-vent/__tests__/geometry.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test' import { getDutchRoofMetrics, getRidgeVentLinesForSegment, RoofSegmentNode } from '@pascal-app/core' -import type * as THREE from 'three' +import * as THREE from 'three' import { getRoofTopSurfaceY } from '../../shared/roof-surface' import { buildRidgeVentGeometry } from '../geometry' import { RidgeVentNode } from '../schema' @@ -375,3 +375,22 @@ describe('buildRidgeVentGeometry', () => { expect(bodyWidth).toBeGreaterThan(startWidth + 0.015) }) }) + +test('shingle seams have a closed visible top surface', () => { + const node = RidgeVentNode.parse({ style: 'shingled', length: 2.1, height: 0.1 }) + const geometry = buildRidgeVentGeometry(node) + const mesh = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial({ side: THREE.DoubleSide })) + const ray = new THREE.Raycaster(new THREE.Vector3(-0.155, 1, 0), new THREE.Vector3(0, -1, 0)) + const hit = ray.intersectObject(mesh)[0] + expect(hit).toBeDefined() + expect(hit!.point.y).toBeGreaterThan(node.height) + geometry.dispose() + mesh.material.dispose() +}) +test('short metal profiles retain finite geometry within their requested height', () => { + const geometry = buildRidgeVentGeometry(RidgeVentNode.parse({ style: 'metal', height: 0.01 })) + geometry.computeBoundingBox() + expect(geometry.boundingBox!.max.y).toBeCloseTo(0.01) + expectFinitePositions(geometry) + geometry.dispose() +}) diff --git a/packages/nodes/src/ridge-vent/definition.ts b/packages/nodes/src/ridge-vent/definition.ts index 1be7ae925e..98a2739095 100644 --- a/packages/nodes/src/ridge-vent/definition.ts +++ b/packages/nodes/src/ridge-vent/definition.ts @@ -161,6 +161,7 @@ export const ridgeVentDefinition: NodeDefinition = { schema: RidgeVentNode, category: 'structure', surfaceRole: 'roof', + snapProfile: 'item', defaults: () => { const stub = RidgeVentNodeSchema.parse({ @@ -201,6 +202,7 @@ export const ridgeVentDefinition: NodeDefinition = { }, toolHints: [ { key: 'Left click', label: 'Place ridge vent on roof' }, + { key: 'Alt', label: 'Place freely' }, { key: 'Esc', label: 'Cancel' }, ], diff --git a/packages/nodes/src/ridge-vent/floorplan.ts b/packages/nodes/src/ridge-vent/floorplan.ts index 3261b7fd38..69e0feb7b9 100644 --- a/packages/nodes/src/ridge-vent/floorplan.ts +++ b/packages/nodes/src/ridge-vent/floorplan.ts @@ -137,6 +137,13 @@ export function buildRidgeVentFloorplan( // Ridge crest line down the centre. seg([-halfLen, 0], [halfLen, 0], lineWidth * 0.8, 0.7) + if (node.style === 'metal') { + for (const side of [-1, 1]) { + seg([-halfLen, side * halfW * 0.5], [halfLen, side * halfW * 0.5], lineWidth * 0.6, 0.7) + seg([-halfLen, side * halfW * 0.94], [halfLen, side * halfW * 0.94], lineWidth * 0.6, 0.7) + } + } + // Shingled style: tab dividers across the width at the cap pitch. if (node.style === 'shingled') { const total = halfLen * 2 diff --git a/packages/nodes/src/ridge-vent/geometry.ts b/packages/nodes/src/ridge-vent/geometry.ts index 8984a5ea33..5b434062c0 100644 --- a/packages/nodes/src/ridge-vent/geometry.ts +++ b/packages/nodes/src/ridge-vent/geometry.ts @@ -71,7 +71,7 @@ export function buildRidgeVentGeometry( const halfW = width / 2 // Band thickness. Generous enough to read as a solid cap; the eave faces // are `t` tall, which is the depth the user actually sees from the side. - const t = Math.max(0.02, h * 0.4) + const t = Math.min(h * 0.8, Math.max(0.005, h * 0.4)) const centerX = finiteNumber(node.position?.[0], 0) const centerZ = finiteNumber(node.position?.[2], 0) @@ -225,10 +225,14 @@ function metalTop(halfW: number, h: number, t: number): ProfilePoint[] { const shoulderY = t + (h - t) * 0.5 return [ [-halfW, t], + [-halfW * 0.94, t], + [-halfW * 0.94, t * 1.2], [-halfW * 0.82, shoulderY], [-seamHalf, h], [seamHalf, h], [halfW * 0.82, shoulderY], + [halfW * 0.94, t * 1.2], + [halfW * 0.94, t], [halfW, t], ] } @@ -419,6 +423,31 @@ function addShingledTabs( const by1 = seatYAt(backX, z1) + capY1 const br0y = by0 + ny * ridgeH const br1y = by1 + ny * ridgeH + pushQuad( + positions, + normals, + uvs, + [backX, br0y, r0z], + [x, r0y, r0z], + [x, r1y, r1z], + [backX, br1y, r1z], + [0, ny, nz], + ) + for (const [z, y, by, rz, ry, bry, sign] of [ + [z0, y0, by0, r0z, r0y, br0y, -1], + [z1, y1, by1, r1z, r1y, br1y, 1], + ]) { + pushQuad( + positions, + normals, + uvs, + [backX, by!, z!], + [x, y!, z!], + [x, ry!, rz!], + [backX, bry!, rz!], + [0, 0, sign!], + ) + } pushQuad( positions, normals, diff --git a/packages/nodes/src/ridge-vent/index.ts b/packages/nodes/src/ridge-vent/index.ts index a57c1a642c..15ae4c25b2 100644 --- a/packages/nodes/src/ridge-vent/index.ts +++ b/packages/nodes/src/ridge-vent/index.ts @@ -1,3 +1,4 @@ export { ridgeVentDefinition } from './definition' export { buildRidgeVentGeometry } from './geometry' +export { resolveRidgeVentPlacement } from './placement' export { RidgeVentNode } from './schema' diff --git a/packages/nodes/src/ridge-vent/move-tool.tsx b/packages/nodes/src/ridge-vent/move-tool.tsx index 70508f5eb0..6b2ba0247d 100644 --- a/packages/nodes/src/ridge-vent/move-tool.tsx +++ b/packages/nodes/src/ridge-vent/move-tool.tsx @@ -11,8 +11,11 @@ import { useScene, } from '@pascal-app/core' import { + commitFreshPlacementSubtree, consumePlacementDragRelease, + isGridSnapActive, markToolCancelConsumed, + stripPlacementMetadataFlags, triggerSFX, useEditor, } from '@pascal-app/editor' @@ -21,20 +24,19 @@ import { createRelativeRoofDrag, type RelativeRoofDragTarget, roofSegmentLocalToBuildingLocal, - snapRelativeRoofDragTarget, } from '../shared/relative-roof-drag' -import { resolveRidgeSnap } from '../shared/ridge-snap' -import { getRoofTopSurfaceY } from '../shared/roof-surface' import { clearRoofSurfacePlacementGuides, publishRoofSurfaceNodePlacementGuides, } from '../shared/roof-surface-placement-guides' +import { resolveRidgeVentPlacement } from './placement' import RidgeVentPreview from './preview' type RidgeVentDragTarget = Pick & { localY: number localZ: number rotation: number + valid: boolean } /** @@ -51,6 +53,7 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) { const [previewPos, setPreviewPos] = useState<[number, number, number] | null>(null) const [previewYaw, setPreviewYaw] = useState(0) + const [previewTarget, setPreviewTarget] = useState(null) useEffect(() => { useScene.temporal.getState().pause() @@ -80,22 +83,34 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) { lastTarget = null lastSnap = null setPreviewPos(null) + setPreviewTarget(null) clearRoofSurfacePlacementGuides() } const resolveTarget = (event: RoofEvent): RidgeVentDragTarget | null => { const rawTarget = roofDrag.resolve(event) if (!rawTarget) return null - const target = snapRelativeRoofDragTarget(rawTarget, event.nativeEvent?.shiftKey === true) - const snap = resolveRidgeSnap(target.segment, target.localX, target.localZ) + const snap = resolveRidgeVentPlacement( + rawTarget.segment, + node, + rawTarget.localX, + rawTarget.localZ, + { + gridStep: + event.nativeEvent?.altKey !== true && isGridSnapActive() + ? useEditor.getState().gridSnapStep + : 0, + free: event.nativeEvent?.altKey === true, + }, + ) if (!snap) return null - const yOffset = original.position[1] ?? 0 return { - segment: target.segment, - localX: snap.localX, - localY: getRoofTopSurfaceY(snap.localX, snap.localZ, target.segment) + yOffset, - localZ: snap.localZ, + segment: rawTarget.segment, + localX: snap.position[0], + localZ: snap.position[2], + localY: snap.surfaceY, rotation: snap.rotation, + valid: snap.valid, } } @@ -106,18 +121,20 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) { return } lastTarget = target + setPreviewTarget(target) const sx = Math.round(target.localX * 20) / 20 const sz = Math.round(target.localZ * 20) / 20 if ( - event.nativeEvent?.shiftKey !== true && + event.nativeEvent?.altKey !== true && + isGridSnapActive() && (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) ) { triggerSFX('sfx:grid-snap') lastSnap = [sx, sz] } - setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0) + target.rotation) + setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0)) setPreviewPos( roofSegmentLocalToBuildingLocal(target.segment.id, [ target.localX, @@ -137,39 +154,30 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) { const onRoofClick = (event: RoofEvent) => { if (committed) return - const target = lastTarget ?? resolveTarget(event) - if (!target) return + const target = event.node ? resolveTarget(event) : lastTarget + if (!target?.valid) return committed = true const targetSegmentId = target.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, { + const patch = { roofSegmentId: targetSegmentId, parentId: targetSegmentId, - position: [target.localX, original.position[1] ?? 0, target.localZ], + position: [target.localX, original.position[1] ?? 0, target.localZ] as [ + number, + number, + number, + ], rotation: target.rotation, visible: true, - metadata: {}, - }) - useScene.temporal.getState().pause() + metadata: stripPlacementMetadataFlags(original.metadata) as RidgeVentNode['metadata'], + } + if (isNew) commitFreshPlacementSubtree(node.id, patch) + else { + useScene.temporal.getState().resume() + st.updateNode(node.id, patch) + useScene.temporal.getState().pause() + } st.dirtyNodes.add(targetSegmentId) st.dirtyNodes.add(node.id as AnyNodeId) @@ -252,12 +260,20 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) { } }, [exitMoveMode, node]) - if (!previewPos) return null + if (!previewPos || !previewTarget) return null return ( - + ) diff --git a/packages/nodes/src/ridge-vent/panel.tsx b/packages/nodes/src/ridge-vent/panel.tsx index a644058b1a..0410d2c1d3 100644 --- a/packages/nodes/src/ridge-vent/panel.tsx +++ b/packages/nodes/src/ridge-vent/panel.tsx @@ -1,6 +1,12 @@ 'use client' -import { type AnyNode, type AnyNodeId, type RoofSegmentNode, useScene } from '@pascal-app/core' +import { + type AnyNode, + type AnyNodeId, + type RoofSegmentNode, + useLiveNodeOverrides, + useScene, +} from '@pascal-app/core' import { ActionButton, ActionGroup, @@ -15,6 +21,7 @@ import { import { useViewer } from '@pascal-app/viewer' import { Copy, Move, Trash2 } from 'lucide-react' import { useCallback } from 'react' +import { resolveRidgeVentPlacement } from './placement' import type { RidgeVentNode } from './schema' /** @@ -29,10 +36,18 @@ export default function RidgeVentPanel() { const deleteNode = useScene((s) => s.deleteNode) const setMovingNode = useEditor((s) => s.setMovingNode) - const node = useScene((s) => + const storeNode = useScene((s) => selectedId ? (s.nodes[selectedId as AnyNode['id']] as RidgeVentNode | undefined) : undefined, ) + const overrides = useLiveNodeOverrides((s) => + selectedId ? s.get(selectedId as AnyNodeId) : undefined, + ) + const node = storeNode ? ({ ...storeNode, ...overrides } as RidgeVentNode) : undefined + const previewProp = (patch: Partial) => { + if (selectedId) useLiveNodeOverrides.getState().set(selectedId as AnyNodeId, patch) + } + const segment = useScene((s) => node?.roofSegmentId ? (s.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined) @@ -43,6 +58,7 @@ export default function RidgeVentPanel() { (updates: Partial) => { if (!selectedId) return updateNode(selectedId as AnyNode['id'], updates) + useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId) }, [selectedId, updateNode], ) @@ -73,15 +89,6 @@ export default function RidgeVentPanel() { 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) @@ -93,32 +100,52 @@ export default function RidgeVentPanel() { 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 + const fit = segment + ? resolveRidgeVentPlacement(segment, node, node.position[0], node.position[2], { fit: true }) + : null + const align = segment + ? resolveRidgeVentPlacement(segment, node, node.position[0], node.position[2]) + : null return ( - handleUpdate({ style: v as RidgeVentNode['style'] })} - options={[ - { label: 'Standard', value: 'standard' }, - { label: 'Shingled', value: 'shingled' }, - { label: 'Flanged', value: 'metal' }, - ]} - value={node.style ?? 'standard'} - /> +
+ {(['standard', 'shingled', 'metal'] as const).map((style) => ( + + ))} +
+

+ {node.style === 'standard' + ? 'Rounded continuous cap.' + : node.style === 'shingled' + ? 'Peaked cap with raised shingle seams.' + : 'Folded cap with a broad crown and edge hems.'} +

+
+ handleUpdate({ endCaps: v === 'yes' })} options={[ @@ -129,12 +156,42 @@ export default function RidgeVentPanel() { /> + +
+ + +
+

+ {fit + ? `Available ridge run: ${fit.availableLength.toFixed(2)} m` + : 'This roof has no supported ridge run.'} +

+
+ handleUpdate({ length: v })} + min={0.2} + onChange={(v) => previewProp({ length: v })} onCommit={(v) => handleUpdate({ length: v })} precision={2} restoreOnCommit={false} @@ -144,9 +201,8 @@ export default function RidgeVentPanel() { /> handleUpdate({ width: v })} + onChange={(v) => previewProp({ width: v })} onCommit={(v) => handleUpdate({ width: v })} precision={2} restoreOnCommit={false} @@ -156,9 +212,8 @@ export default function RidgeVentPanel() { /> handleUpdate({ height: v })} + min={0.02} + onChange={(v) => previewProp({ height: v })} onCommit={(v) => handleUpdate({ height: v })} precision={3} restoreOnCommit={false} @@ -168,13 +223,11 @@ export default function RidgeVentPanel() { /> - + - handleUpdate({ + previewProp({ position: [v, node.position[1] ?? 0, node.position[2] ?? 0], }) } @@ -190,9 +243,9 @@ export default function RidgeVentPanel() { value={node.position[0] ?? 0} /> - handleUpdate({ + previewProp({ position: [node.position[0] ?? 0, v, node.position[2] ?? 0], }) } @@ -209,10 +262,8 @@ export default function RidgeVentPanel() { /> - handleUpdate({ + previewProp({ position: [node.position[0] ?? 0, node.position[1] ?? 0, v], }) } @@ -231,7 +282,7 @@ export default function RidgeVentPanel() { label="Rotation" max={180} min={-180} - onChange={(deg) => handleUpdate({ rotation: (deg * Math.PI) / 180 })} + onChange={(deg) => previewProp({ rotation: (deg * Math.PI) / 180 })} onCommit={(deg) => handleUpdate({ rotation: (deg * Math.PI) / 180 })} precision={0} restoreOnCommit={false} diff --git a/packages/nodes/src/ridge-vent/parametrics.ts b/packages/nodes/src/ridge-vent/parametrics.ts index 583cee381b..bd5c3c543e 100644 --- a/packages/nodes/src/ridge-vent/parametrics.ts +++ b/packages/nodes/src/ridge-vent/parametrics.ts @@ -22,9 +22,9 @@ export const ridgeVentParametrics: ParametricDescriptor = { { label: 'Dimensions', fields: [ - { key: 'length', kind: 'number', unit: 'm', min: 0.5, max: 1000, 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 }, + { key: 'length', kind: 'number', unit: 'm', min: 0.2, step: 0.05 }, + { key: 'width', kind: 'number', unit: 'm', min: 0.1, step: 0.01 }, + { key: 'height', kind: 'number', unit: 'm', min: 0.02, step: 0.005 }, ], }, ], diff --git a/packages/nodes/src/ridge-vent/placement.test.ts b/packages/nodes/src/ridge-vent/placement.test.ts new file mode 100644 index 0000000000..c298c29673 --- /dev/null +++ b/packages/nodes/src/ridge-vent/placement.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from 'bun:test' +import { RoofSegmentNode } from '@pascal-app/core' +import { RidgeVentNode, resolveRidgeVentPlacement } from './index' + +const segment = RoofSegmentNode.parse({ + roofType: 'gable', + width: 8, + depth: 6, + overhang: 0, + gableOverhang: 0, + shingleThickness: 0, + wallThickness: 0, +}) +const vent = RidgeVentNode.parse({ length: 2 }) +describe('ridge vent placement', () => { + test('keeps the whole length on the ridge, snaps along it and preserves roof offset', () => { + const end = resolveRidgeVentPlacement(segment, vent, 4, 2)! + expect(end.position).toEqual([3, 0, 0]) + const grid = resolveRidgeVentPlacement(segment, { ...vent, position: [0, 0.2, 0] }, 0.13, 2, { + gridStep: 0.25, + })! + expect(grid.position).toEqual([0.25, 0.2, 0]) + expect(grid.surfaceY - end.surfaceY).toBeCloseTo(0.2) + expect(resolveRidgeVentPlacement(segment, vent, 0.13, 2)!.position[0]).toBeCloseTo(0.13) + }) + test('fits to trimmed span and rejects an oversized vent', () => { + const roof = RoofSegmentNode.parse({ ...segment, trim: { left: 2, right: 1 } }) + const fit = resolveRidgeVentPlacement(roof, vent, 0, 0, { fit: true })! + expect(fit.length).toBeCloseTo(5) + expect(fit.position[0]).toBeCloseTo(0.5) + expect(resolveRidgeVentPlacement(roof, { ...vent, length: 6 }, 0, 0)!.valid).toBe(false) + }) + test('accounts for vent width at a diagonal trim', () => { + const roof = RoofSegmentNode.parse({ ...segment, trim: { frontLeftX: 2, frontLeftZ: 4 } }) + const narrow = resolveRidgeVentPlacement(roof, vent, 0, 0, { fit: true })! + const wide = resolveRidgeVentPlacement(roof, { ...vent, width: 1 }, 0, 0, { fit: true })! + expect(wide.length).toBeLessThan(narrow.length) + }) + test('follows a diagonal hip and Alt preserves the raw position', () => { + const hip = RoofSegmentNode.parse({ ...segment, roofType: 'hip' }) + const placed = resolveRidgeVentPlacement(hip, { ...vent, length: 1 }, -2.5, 1.5)! + expect(Math.abs(placed.rotation)).toBeCloseTo(Math.PI / 4) + expect(placed.valid).toBe(true) + expect(resolveRidgeVentPlacement(segment, vent, 0.13, 2, { free: true })!.position).toEqual([ + 0.13, 0, 2, + ]) + }) + test('does not offer ridges on flat, glazed, barrel or conical roofs', () => { + for (const roofType of ['flat', 'barrel', 'conical'] as const) + expect(resolveRidgeVentPlacement({ ...segment, roofType }, vent, 0, 0)).toBeNull() + expect(resolveRidgeVentPlacement({ ...segment, roofSystem: 'glazed' }, vent, 0, 0)).toBeNull() + }) +}) diff --git a/packages/nodes/src/ridge-vent/placement.ts b/packages/nodes/src/ridge-vent/placement.ts new file mode 100644 index 0000000000..47b0a50f12 --- /dev/null +++ b/packages/nodes/src/ridge-vent/placement.ts @@ -0,0 +1,115 @@ +import { + getRidgeVentLinesForSegment, + getRoofSegmentVisibleTopBounds, + normalizeRoofSegmentTrim, + type RidgeVentNode, + type RoofSegmentNode, +} from '@pascal-app/core' +import { getRoofTopSurfaceY } from '../shared/roof-surface' + +// Trim planes are linear, so clipping the two long edges also protects every +// point between them without sampling (including narrow diagonal cuts). +function trimDistances(segment: RoofSegmentNode, x: number, z: number): number[] { + const t = normalizeRoofSegmentTrim(segment) + const l = -segment.width / 2 + t.left, + r = segment.width / 2 - t.right + const b = -segment.depth / 2 + t.back, + f = segment.depth / 2 - t.front + const values: number[] = [] + if (t.left > 0) values.push(x - l) + if (t.right > 0) values.push(r - x) + if (t.back > 0) values.push(z - b) + if (t.front > 0) values.push(f - z) + for (const [dx, dz, tx, tz] of [ + [x - l, f - z, t.frontLeftX, t.frontLeftZ], + [r - x, f - z, t.frontRightX, t.frontRightZ], + [x - l, z - b, t.backLeftX, t.backLeftZ], + [r - x, z - b, t.backRightX, t.backRightZ], + ]) { + if (tx! > 0 && tz! > 0) values.push(dx! / tx! + dz! / tz! - 1) + } + return values +} + +export function resolveRidgeVentPlacement( + segment: RoofSegmentNode, + node: RidgeVentNode, + x: number, + z: number, + options: { gridStep?: number; free?: boolean; fit?: boolean } = {}, +) { + if (segment.roofSystem === 'glazed') return null + const bounds = getRoofSegmentVisibleTopBounds(segment) + const lines = + segment.roofType === 'shed' + ? [{ start: [bounds.minX, bounds.minZ], end: [bounds.maxX, bounds.minZ] }] + : getRidgeVentLinesForSegment(segment) + let best: { + position: [number, number, number] + rotation: number + length: number + availableLength: number + valid: boolean + surfaceY: number + } | null = null + let bestDistance = Infinity + for (const line of lines) { + const [sx, sz] = line.start, + [ex, ez] = line.end + const dx = ex! - sx!, + dz = ez! - sz!, + span = Math.hypot(dx, dz) + if (span < 0.001) continue + const ux = dx / span, + uz = dz / span + let lo = 0, + hi = 1 + for (const side of [-1, 1]) { + const ox = ((-uz * node.width) / 2) * side, + oz = ((ux * node.width) / 2) * side + const a = trimDistances(segment, sx! + ox, sz! + oz), + b = trimDistances(segment, ex! + ox, ez! + oz) + for (let i = 0; i < a.length; i++) { + const start = a[i]!, + end = b[i]! + if (start < 0 && end < 0) { + lo = 1 + hi = 0 + break + } + if (start < 0) lo = Math.max(lo, -start / (end - start)) + if (end < 0) hi = Math.min(hi, start / (start - end)) + } + } + if (hi <= lo) continue + const availableLength = (hi - lo) * span + const length = options.fit ? availableLength : node.length + const raw = (x - sx!) * ux + (z - sz!) * uz + const half = Math.min(length, availableLength) / 2 + const min = lo * span + half, + max = hi * span - half + let distance = options.fit ? (min + max) / 2 : raw + if (options.gridStep && options.gridStep > 0) + distance = + Math.round((distance + sx! * ux + sz! * uz) / options.gridStep) * options.gridStep - + sx! * ux - + sz! * uz + distance = Math.max(min, Math.min(max, distance)) + const cx = options.free ? x : sx! + ux * distance, + cz = options.free ? z : sz! + uz * distance + const score = + (x - (sx! + ux * Math.max(lo * span, Math.min(hi * span, raw)))) ** 2 + + (z - (sz! + uz * Math.max(lo * span, Math.min(hi * span, raw)))) ** 2 + if (score >= bestDistance) continue + bestDistance = score + best = { + position: [cx, node.position[1], cz], + rotation: Math.atan2(-dz, dx), + length, + availableLength, + valid: options.free === true || length <= availableLength + 1e-6, + surfaceY: getRoofTopSurfaceY(cx, cz, segment) + Math.max(-2, Math.min(2, node.position[1])), + } + } + return best +} diff --git a/packages/nodes/src/ridge-vent/preview.tsx b/packages/nodes/src/ridge-vent/preview.tsx index 9fc5bf4232..b1299e4c16 100644 --- a/packages/nodes/src/ridge-vent/preview.tsx +++ b/packages/nodes/src/ridge-vent/preview.tsx @@ -1,16 +1,35 @@ 'use client' +import type { RoofSegmentNode } from '@pascal-app/core' import { useEffect, useMemo } from 'react' import * as THREE from 'three' import { INVALID_GHOST_COLOR } from '../shared/ghost-materials' import { buildRidgeVentGeometry } from './geometry' import type { RidgeVentNode } from './schema' -const RidgeVentPreview = ({ node, invalid }: { node: RidgeVentNode; invalid?: boolean }) => { +const RidgeVentPreview = ({ + node, + invalid, + segment, +}: { + node: RidgeVentNode + invalid?: boolean + segment?: RoofSegmentNode +}) => { // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. const geometry = useMemo( - () => buildRidgeVentGeometry(node), - [node.length, node.width, node.height, node.style, node.endCaps], + () => buildRidgeVentGeometry(node, segment), + [ + node.length, + node.width, + node.height, + node.style, + node.endCaps, + node.rotation, + node.position[0], + node.position[2], + segment, + ], ) const material = useMemo( @@ -31,14 +50,9 @@ const RidgeVentPreview = ({ node, invalid }: { node: RidgeVentNode; invalid?: bo const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry]) - useEffect( - () => () => { - geometry.dispose() - edgesGeometry.dispose() - material.dispose() - }, - [geometry, edgesGeometry, material], - ) + useEffect(() => () => geometry.dispose(), [geometry]) + useEffect(() => () => edgesGeometry.dispose(), [edgesGeometry]) + useEffect(() => () => material.dispose(), [material]) return ( @@ -50,7 +64,12 @@ const RidgeVentPreview = ({ node, invalid }: { node: RidgeVentNode; invalid?: bo }} /> - + ) diff --git a/packages/nodes/src/ridge-vent/renderer.tsx b/packages/nodes/src/ridge-vent/renderer.tsx index 78f0fb200e..9516c3d47d 100644 --- a/packages/nodes/src/ridge-vent/renderer.tsx +++ b/packages/nodes/src/ridge-vent/renderer.tsx @@ -23,7 +23,6 @@ import { } from '@pascal-app/viewer' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' -import { RIDGE_LIFT, resolveRidgeSnap } from '../shared/ridge-snap' import { getRoofTopSurfaceY } from '../shared/roof-surface' import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip' import { buildRidgeVentGeometry } from './geometry' @@ -130,15 +129,8 @@ const RidgeVentRenderer = ({ node: storeNode }: { node: RidgeVentNode }) => { const sceneMaterials = useScene((state) => state.materials) const segmentGeometryKey = ridgeVentSegmentGeometryKey(segment) const rotationY = node.rotation ?? 0 - const snap = useMemo( - () => - segment && Math.abs(rotationY) < 1e-5 - ? resolveRidgeSnap(segment, nodePosition[0] ?? 0, nodePosition[2] ?? 0) - : null, - [segment, rotationY, nodePosition[0], nodePosition[2]], - ) - const ridgeX = snap ? snap.localX : (nodePosition[0] ?? 0) - const ridgeZ = snap ? snap.localZ : (nodePosition[2] ?? 0) + const ridgeX = nodePosition[0] ?? 0 + const ridgeZ = nodePosition[2] ?? 0 const effectiveNode = useMemo( () => ({ ...node, @@ -231,7 +223,7 @@ const RidgeVentRenderer = ({ node: storeNode }: { node: RidgeVentNode }) => { if (!segment) return new THREE.Matrix4() const baseY = getRoofTopSurfaceY(ridgeX, ridgeZ, segment) const yOffset = Math.max(-2, Math.min(2, nodePosition[1] ?? 0)) - const ridgeY = baseY + RIDGE_LIFT + yOffset + const ridgeY = baseY + yOffset return new THREE.Matrix4().compose( new THREE.Vector3(ridgeX, ridgeY, ridgeZ), new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), rotationY), @@ -264,7 +256,7 @@ const RidgeVentRenderer = ({ node: storeNode }: { node: RidgeVentNode }) => { // Clamp legacy stored Y (absolute peak height from earlier versions) so the // vent doesn't fly off when the field was an absolute Y instead of offset. const yOffset = Math.max(-2, Math.min(2, nodePosition[1] ?? 0)) - const ridgeY = baseY + RIDGE_LIFT + yOffset + const ridgeY = baseY + yOffset return ( diff --git a/packages/nodes/src/ridge-vent/tool.test.tsx b/packages/nodes/src/ridge-vent/tool.test.tsx new file mode 100644 index 0000000000..4a7de99422 --- /dev/null +++ b/packages/nodes/src/ridge-vent/tool.test.tsx @@ -0,0 +1,88 @@ +import { expect, test } from 'bun:test' +import { + BuildingNode, + emitter, + LevelNode, + nodeRegistry, + RoofNode, + RoofSegmentNode, + registerNode, + sceneRegistry, + useScene, +} from '@pascal-app/core' +import { useEditor, usePlacementPreview } from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { act, create } from '@react-three/test-renderer' +import * as THREE from 'three' +import { buildRidgeVentGeometry, ridgeVentDefinition } from './index' + +test('ridge-vent placement matches its preview, honors grid mode and blocks unsupported drops', async () => { + const restoreRegistry = nodeRegistry._snapshot() + if (!nodeRegistry.get('ridge-vent')) registerNode(ridgeVentDefinition) + const savedEditor = useEditor.getState() + useEditor.setState({ mode: 'build', tool: 'ridge-vent', gridSnapStep: 0.25 }) + useEditor.getState().setSnappingMode('item', 'grid') + const savedScene = useScene.getState() + const savedSelection = useViewer.getState().selection + const building = BuildingNode.parse({}) + const level = LevelNode.parse({ parentId: building.id }) + const roof = RoofNode.parse({ parentId: level.id }) + const segment = RoofSegmentNode.parse({ parentId: roof.id, width: 20, depth: 20 }) + roof.children = [segment.id] + level.children = [roof.id] + building.children = [level.id] + useScene.setState({ + nodes: { [building.id]: building, [level.id]: level, [roof.id]: roof, [segment.id]: segment }, + rootNodeIds: [building.id], + dirtyNodes: new Set(), + readOnly: false, + }) + useViewer.getState().setSelection({ buildingId: building.id, levelId: level.id }) + sceneRegistry.nodes.set(segment.id, new THREE.Group()) + const { default: Tool } = await ridgeVentDefinition.tool!() + const renderer = await create() + try { + const event = { + node: roof, + position: [0.13, 3, 3.13] as [number, number, number], + stopPropagation: () => {}, + } + await act(async () => emitter.emit('roof:move', event)) + const preview = usePlacementPreview.getState().node + expect(preview?.type).toBe('ridge-vent') + if (preview?.type !== 'ridge-vent') throw new Error('Missing ridge-vent preview') + await act(async () => emitter.emit('roof:click', event)) + const placed = Object.values(useScene.getState().nodes).find( + (node) => node.type === 'ridge-vent', + ) + expect(placed).toBeDefined() + if (placed?.type !== 'ridge-vent') throw new Error('Missing ridge vent') + expect(preview.position).toEqual(placed.position) + expect(placed.position[0]).toBe(0.25) + expect(placed.position[2]).toBe(0) + expect(preview.id).not.toBe(placed.id) + const previewGeometry = buildRidgeVentGeometry(preview, segment)! + const placedGeometry = buildRidgeVentGeometry(placed, segment)! + previewGeometry.computeBoundingBox() + placedGeometry.computeBoundingBox() + expect(previewGeometry.boundingBox!.equals(placedGeometry.boundingBox!)).toBe(true) + previewGeometry.dispose() + placedGeometry.dispose() + expect(usePlacementPreview.getState().node).toBeNull() + useScene.setState({ + nodes: { ...useScene.getState().nodes, [segment.id]: { ...segment, roofType: 'flat' } }, + }) + await act(async () => emitter.emit('roof:click', event)) + expect( + Object.values(useScene.getState().nodes).filter((n) => n.type === 'ridge-vent'), + ).toHaveLength(1) + } finally { + restoreRegistry() + useEditor.setState(savedEditor) + await renderer.unmount() + sceneRegistry.nodes.delete(segment.id) + useScene.setState(savedScene) + useViewer.getState().setSelection(savedSelection) + usePlacementPreview.getState().clear() + } +}) diff --git a/packages/nodes/src/ridge-vent/tool.tsx b/packages/nodes/src/ridge-vent/tool.tsx index ae87125829..c38df119fc 100644 --- a/packages/nodes/src/ridge-vent/tool.tsx +++ b/packages/nodes/src/ridge-vent/tool.tsx @@ -5,187 +5,148 @@ import { emitter, RidgeVentNode, type RoofEvent, - type RoofNode, - sceneRegistry, + type RoofSegmentNode, useScene, } from '@pascal-app/core' -import { triggerSFX } from '@pascal-app/editor' +import { isGridSnapActive, triggerSFX, useEditor, usePlacementPreview } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { useEffect, useMemo, useRef, useState } from 'react' -import * as THREE from 'three' -import { resolveRidgeSnap } from '../shared/ridge-snap' +import { useEffect, useMemo, useState } from 'react' +import { roofSegmentLocalToBuildingLocal } from '../shared/relative-roof-drag' import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' -import { getRoofTopSurfaceY } from '../shared/roof-surface' import { clearRoofSurfacePlacementGuides, - publishRoofSurfacePlacementGuides, - roofSurfaceFootprintFromNode, + publishRoofSurfaceNodePlacementGuides, } from '../shared/roof-surface-placement-guides' import { ridgeVentDefinition } from './definition' +import { resolveRidgeVentPlacement } from './placement' import RidgeVentPreview from './preview' -const worldPoint = new THREE.Vector3() - -/** - * Ridge vent placement tool. The cursor preview snaps to the nearest - * ridge/break line of whichever segment is under the cursor, since the - * cap needs to straddle a real roof crease. - */ -const RidgeVentTool = () => { +export default function RidgeVentTool() { const activeBuildingId = useViewer((s) => s.selection.buildingId) - const setSelection = useViewer((s) => s.setSelection) - - const [previewPos, setPreviewPos] = useState<[number, number, number] | null>(null) - const [previewYaw, setPreviewYaw] = useState(0) - const lastSnapRef = useRef<[number, number] | null>(null) - - const previewNode = useMemo( - () => - RidgeVentNode.parse({ - ...ridgeVentDefinition.defaults(), - name: 'Ridge Vent', - position: [0, 0, 0], - rotation: 0, - }), + const seed = useMemo( + () => RidgeVentNode.parse({ ...ridgeVentDefinition.defaults(), name: 'Ridge Vent' }), [], ) + const [preview, setPreview] = useState<{ + node: RidgeVentNode + position: [number, number, number] + segment: RoofSegmentNode + yaw: number + valid: boolean + } | null>(null) 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 clear = () => { + setPreview(null) + if (usePlacementPreview.getState().node?.id === seed.id) + usePlacementPreview.getState().clear() + clearRoofSurfacePlacementGuides() } - - const updatePreview = (event: RoofEvent) => { - const hit = resolveRoofSegmentHit( - event.node as RoofNode, - event.position[0], - event.position[1], - event.position[2], - ) - if (!hit) return - - // Project the cursor onto the nearest segment ridge/break line. - // The preview then moves along that line as the cursor moves — never - // off it. Flat segments have no ridge: hide. - const snap = resolveRidgeSnap(hit.segment, hit.localX, hit.localZ) - if (!snap) { - setPreviewPos(null) - clearRoofSurfacePlacementGuides() + const resolve = (event: RoofEvent) => { + const hit = resolveRoofSegmentHit(event.node, ...event.position) + if (!hit) return null + const force = event.nativeEvent?.altKey === true + const placement = resolveRidgeVentPlacement(hit.segment, seed, hit.localX, hit.localZ, { + gridStep: !force && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0, + free: force, + }) + if (!placement) return null + return { hit, placement, force } + } + const show = (event: RoofEvent) => { + const resolved = resolve(event) + if (!resolved) { + clear() return } - const segObj = sceneRegistry.nodes.get(hit.segment.id) - let ridgeWorld: [number, number, number] - if (segObj) { - const ridgeLocal = new THREE.Vector3( - snap.localX, - getRoofTopSurfaceY(snap.localX, snap.localZ, hit.segment), - snap.localZ, - ) - segObj.updateWorldMatrix(true, false) - ridgeLocal.applyMatrix4(segObj.matrixWorld) - ridgeWorld = [ridgeLocal.x, ridgeLocal.y, ridgeLocal.z] - } else { - ridgeWorld = [event.position[0], event.position[1], event.position[2]] - } - - const sx = Math.round(ridgeWorld[0] * 20) / 20 - const sz = Math.round(ridgeWorld[2] * 20) / 20 - const prev = lastSnapRef.current - if (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== sx || prev[1] !== sz)) { - triggerSFX('sfx:grid-snap') - lastSnapRef.current = [sx, sz] + const { hit, placement, force } = resolved + const node = { + ...seed, + position: placement.position, + rotation: placement.rotation, + parentId: hit.segment.id, + roofSegmentId: hit.segment.id, } - - setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0) + snap.rotation) - setPreviewPos(worldToBuildingLocal(ridgeWorld[0], ridgeWorld[1], ridgeWorld[2])) - publishRoofSurfacePlacementGuides({ - roof: event.node as RoofNode, + setPreview({ + node, + position: roofSegmentLocalToBuildingLocal(hit.segment.id, [ + placement.position[0], + placement.surfaceY, + placement.position[2], + ]), segment: hit.segment, - center: [ - snap.localX, - getRoofTopSurfaceY(snap.localX, snap.localZ, hit.segment), - snap.localZ, - ], - footprint: roofSurfaceFootprintFromNode(previewNode), - mode: 'linear-edge', + yaw: event.node.rotation + hit.segment.rotation, + valid: placement.valid || force, + }) + usePlacementPreview.getState().set(node, hit.segment) + publishRoofSurfaceNodePlacementGuides({ + roof: event.node, + segment: hit.segment, + center: [placement.position[0], placement.surfaceY, placement.position[2]], + node, }) 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 snap = resolveRidgeSnap(hit.segment, hit.localX, hit.localZ) - if (!snap) return - const state = useScene.getState() - - const vent = RidgeVentNode.parse({ - ...ridgeVentDefinition.defaults(), - name: 'Ridge Vent', + const place = (event: RoofEvent) => { + const resolved = resolve(event) + if (!resolved) return + const { hit, placement, force } = resolved + if (!placement.valid && !force) { + show(event) + return + } + const node = RidgeVentNode.parse({ + ...seed, + id: undefined, roofSegmentId: hit.segment.id, - position: [snap.localX, 0, snap.localZ], - rotation: snap.rotation, + position: placement.position, + rotation: placement.rotation, }) - state.createNode(vent, hit.segment.id as AnyNodeId) - state.dirtyNodes.add(hit.segment.id as AnyNodeId) - setSelection({ selectedIds: [vent.id] }) + useScene.getState().createNode(node, hit.segment.id as AnyNodeId) + useViewer.getState().setSelection({ selectedIds: [node.id] }) triggerSFX('sfx:item-place') - clearRoofSurfacePlacementGuides() + clear() event.stopPropagation() } - - emitter.on('roof:move', updatePreview) - emitter.on('roof:enter', updatePreview) - emitter.on('roof:click', onClick) - + emitter.on('roof:move', show) + emitter.on('roof:enter', show) + emitter.on('roof:leave', clear) + emitter.on('roof:click', place) + emitter.on('tool:cancel', clear) return () => { - emitter.off('roof:move', updatePreview) - emitter.off('roof:enter', updatePreview) - emitter.off('roof:click', onClick) - clearRoofSurfacePlacementGuides() + emitter.off('roof:move', show) + emitter.off('roof:enter', show) + emitter.off('roof:leave', clear) + emitter.off('roof:click', place) + emitter.off('tool:cancel', clear) + clear() } - }, [activeBuildingId, setSelection, previewNode]) + }, [activeBuildingId, seed]) return ( <> } - isValidRoofTarget={(event) => { - const hit = resolveRoofSegmentHit( - event.node as RoofNode, - event.position[0], - event.position[1], - event.position[2], - ) - return !!hit && !!resolveRidgeSnap(hit.segment, hit.localX, hit.localZ) - }} + ghost={} onInvalidTarget={() => { - setPreviewPos(null) + setPreview(null) + usePlacementPreview.getState().clear() clearRoofSurfacePlacementGuides() }} /> - {activeBuildingId && previewPos && ( - - - + {preview && ( + + + )} ) } - -export default RidgeVentTool diff --git a/packages/nodes/src/shared/connection-feedback.tsx b/packages/nodes/src/shared/connection-feedback.tsx index b8d3c46dd4..909181b97d 100644 --- a/packages/nodes/src/shared/connection-feedback.tsx +++ b/packages/nodes/src/shared/connection-feedback.tsx @@ -1,6 +1,6 @@ 'use client' -import type { AnyNodeId } from '@pascal-app/core' +import { type AnyNodeId, type NodePort, portConnection } from '@pascal-app/core' import { EDITOR_LAYER, isAngleSnapActive, @@ -15,6 +15,7 @@ import { findNearestPort3D, type ScenePort, } from '@pascal-app/editor/distribution' +import { Html } from '@react-three/drei' const COLORS = { match: '#16a34a', adapter: '#d97706', incompatible: '#dc2626', unknown: '#d97706' } @@ -25,7 +26,7 @@ export function ConnectionFeedback({ target, }: { point: [number, number, number] | null - profile: ConnectionProfile + profile: ConnectionProfile & { direction?: NodePort['direction'] } levelId: AnyNodeId target?: ScenePort | null }) { @@ -34,10 +35,24 @@ export function ConnectionFeedback({ const port = target === undefined ? findNearestPort3D(point, collectScenePorts({ levelId }), 0.5) : target if (!port) return null - const feedback = connectionCompatibility(profile, port) + const feedback = profile.direction + ? portConnection( + { ...profile, id: 'preview', position: port.position, direction: profile.direction }, + port, + ) + : connectionCompatibility(profile, port) const color = COLORS[feedback.status] return ( + +
+ {feedback.label} +
+ {}}> diff --git a/packages/nodes/src/shared/port-connectivity-pipe.test.ts b/packages/nodes/src/shared/port-connectivity-pipe.test.ts index 3173e6b10c..a648251e3d 100644 --- a/packages/nodes/src/shared/port-connectivity-pipe.test.ts +++ b/packages/nodes/src/shared/port-connectivity-pipe.test.ts @@ -132,7 +132,7 @@ describe('port connectivity — DWV pipe family', () => { armLengthM: 0, }) const outlet = portsOf('pipe-trap', trap as AnyNode).find((p) => p.id === 'outlet')! - const run = pipeRunFrom(outlet.position) + const run = { ...pipeRunFrom(outlet.position), diameter: trap.diameter } const nodes: Record = { [trap.id]: trap as AnyNode, diff --git a/packages/nodes/src/shared/ridge-snap.ts b/packages/nodes/src/shared/ridge-snap.ts index d5c5c8cdf7..80d7877861 100644 --- a/packages/nodes/src/shared/ridge-snap.ts +++ b/packages/nodes/src/shared/ridge-snap.ts @@ -24,10 +24,6 @@ import { * - flat: no ridge at all → return null. */ -// Ridge vents seat directly onto the analytical roof surface; any visible -// thickness belongs in the vent geometry itself, not in a renderer lift. -export const RIDGE_LIFT = 0.09 - export type RidgeSnap = { /** Segment-local X of the snapped ridge position. */ localX: number diff --git a/packages/nodes/src/shared/roof-footprint-trim.ts b/packages/nodes/src/shared/roof-footprint-trim.ts new file mode 100644 index 0000000000..a0a0babdd9 --- /dev/null +++ b/packages/nodes/src/shared/roof-footprint-trim.ts @@ -0,0 +1,27 @@ +import { normalizeRoofSegmentTrim, type Ring, type RoofSegmentNode } from '@pascal-app/core' + +export function roofFootprintInsideTrim(segment: RoofSegmentNode, points: Ring): boolean { + const t = normalizeRoofSegmentTrim(segment) + const left = -segment.width / 2 + t.left + const right = segment.width / 2 - t.right + const back = -segment.depth / 2 + t.back + const front = segment.depth / 2 - t.front + return points.every(([x, z]) => { + if ( + (t.left > 0 && x < left) || + (t.right > 0 && x > right) || + (t.back > 0 && z < back) || + (t.front > 0 && z > front) + ) + return false + for (const [dx, dz, tx, tz] of [ + [x - left, front - z, t.frontLeftX, t.frontLeftZ], + [right - x, front - z, t.frontRightX, t.frontRightZ], + [x - left, z - back, t.backLeftX, t.backLeftZ], + [right - x, z - back, t.backRightX, t.backRightZ], + ]) { + if (tx! > 0 && tz! > 0 && dx! / tx! + dz! / tz! < 1) return false + } + return true + }) +} diff --git a/packages/nodes/src/shared/system-checks.ts b/packages/nodes/src/shared/system-checks.ts index 335fcc98db..15613fa639 100644 --- a/packages/nodes/src/shared/system-checks.ts +++ b/packages/nodes/src/shared/system-checks.ts @@ -9,9 +9,9 @@ import { getWallEffectiveHeightForNodes, getWallFaceOffsets, nodeRegistry, + portConnection, validateDwv, } from '@pascal-app/core' -import { connectionCompatibility } from '@pascal-app/editor/distribution' import { Box3, Ray, Vector3 } from 'three' import { planRunHangerSlots } from './run-hangers' @@ -32,16 +32,12 @@ export function checkDistributionSystems(nodes: Record): Sys for (let j = i + 1; j < ports.length; j++) { const b = ports[j]! if (a.nodeId === b.nodeId || Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z) > 0.05) continue - joined.add(i) - joined.add(j) - connectedPairs.add([a.nodeId, b.nodeId].sort().join('|')) - const profile = (entry: typeof a) => { - const node = nodes[entry.nodeId] - return node?.type === 'duct-segment' - ? { ...entry.port, shape: node.shape, width: node.width, height: node.height } - : entry.port + const compatibility = portConnection(a.port, b.port) + if (compatibility.status === 'match') { + joined.add(i) + joined.add(j) + connectedPairs.add([a.nodeId, b.nodeId].sort().join('|')) } - const compatibility = connectionCompatibility(profile(a), profile(b)) if (compatibility.status !== 'match') findings.push({ code: 'connection-mismatch', diff --git a/packages/nodes/src/skylight/__tests__/geometry.test.ts b/packages/nodes/src/skylight/__tests__/geometry.test.ts index 5bb9e3920d..498656dbb8 100644 --- a/packages/nodes/src/skylight/__tests__/geometry.test.ts +++ b/packages/nodes/src/skylight/__tests__/geometry.test.ts @@ -1,5 +1,12 @@ import { describe, expect, test } from 'bun:test' -import { buildLanternGlassGeometry, clamp01, paneSize } from '../geometry' +import { Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three' +import { + buildFrameGeometry, + buildLanternGlassGeometry, + clamp01, + paneSize, + SkylightNode, +} from '../index' describe('buildLanternGlassGeometry', () => { test('returns non-empty geometry across topScale variants', () => { @@ -28,3 +35,30 @@ describe('paneSize / clamp01 helpers', () => { expect(clamp01(0.4)).toBe(0.4) }) }) + +test('flat-top lantern has glass over the center of its top opening', () => { + const geometry = buildLanternGlassGeometry(1, 1.2, 0.4, 0.5) + const material = new MeshBasicMaterial() + const mesh = new Mesh(geometry, material) + const hits = new Raycaster(new Vector3(0.05, 1, 0.05), new Vector3(0, -1, 0)).intersectObject( + mesh, + ) + expect(hits.length).toBeGreaterThan(0) + expect(hits[0]!.point.y).toBeCloseTo(0.4) + geometry.dispose() + material.dispose() +}) + +test('frame leaves the glass opening clear and covers the cut edge with flashing', () => { + const node = SkylightNode.parse({ width: 1, height: 1.2, frameThickness: 0.05 }) + const geometry = buildFrameGeometry(node)! + const material = new MeshBasicMaterial() + const mesh = new Mesh(geometry, material) + const ray = (x: number) => + new Raycaster(new Vector3(x, 1, 0), new Vector3(0, -1, 0)).intersectObject(mesh) + expect(ray(0)).toHaveLength(0) + expect(ray(0.58).length).toBeGreaterThan(0) + expect(ray(0.62)).toHaveLength(0) + geometry.dispose() + material.dispose() +}) diff --git a/packages/nodes/src/skylight/definition.ts b/packages/nodes/src/skylight/definition.ts index a7549d3e3b..7f4affe46a 100644 --- a/packages/nodes/src/skylight/definition.ts +++ b/packages/nodes/src/skylight/definition.ts @@ -219,6 +219,7 @@ const skylightHandles: HandleDescriptor[] = [ export const skylightDefinition: NodeDefinition = { kind: 'skylight', schemaVersion: 1, + snapProfile: 'item', schema: SkylightNode, category: 'structure', surfaceRole: 'glazing', @@ -268,6 +269,7 @@ export const skylightDefinition: NodeDefinition = { }, toolHints: [ { key: 'Left click', label: 'Place skylight on roof' }, + { key: 'Alt', label: 'Place freely' }, { key: 'Esc', label: 'Cancel' }, ], diff --git a/packages/nodes/src/skylight/frame-csg.ts b/packages/nodes/src/skylight/frame-csg.ts index da82d81ee3..9b57db8b76 100644 --- a/packages/nodes/src/skylight/frame-csg.ts +++ b/packages/nodes/src/skylight/frame-csg.ts @@ -1,71 +1,46 @@ import type { SkylightNode } from '@pascal-app/core' -import { - Brush, - csgEvaluator, - csgGeometry, - prepareBrushForCSG, - SUBTRACTION, -} from '@pascal-app/viewer' import * as THREE from 'three' - -const visibleDummyMat = new THREE.MeshBasicMaterial() - -export function buildFrameGeometry({ - curb, - curbHeight, - frameDepth, - frameThickness, - height, - width, -}: Pick< - SkylightNode, - 'curb' | 'curbHeight' | 'frameDepth' | 'frameThickness' | 'height' | 'width' ->): THREE.BufferGeometry | null { - const w = width - const h = height - const ft = frameThickness - const fd = frameDepth - const hasCurb = curb ?? false - const curbH = hasCurb ? Math.max(0, curbHeight ?? 0.1) : 0 - - const outerW = w + 2 * ft - const outerH = h + 2 * ft - const totalDepth = fd + curbH - - const outerBox = new THREE.BoxGeometry(outerW, totalDepth, outerH) - const innerBox = new THREE.BoxGeometry(w, totalDepth + 0.02, h) - - const setupGeo = (geo: THREE.BufferGeometry) => { - const ic = geo.getIndex()?.count ?? 0 - geo.clearGroups() - if (ic > 0) geo.addGroup(0, ic, 0) +import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' + +export function buildFrameGeometry( + node: Pick< + SkylightNode, + 'curb' | 'curbHeight' | 'frameDepth' | 'frameThickness' | 'height' | 'width' + >, +): THREE.BufferGeometry | null { + const w = Math.max(0.02, node.width), + d = Math.max(0.02, node.height) + const ft = Math.max(0.005, node.frameThickness), + fd = Math.max(0.005, node.frameDepth) + const curb = node.curb ? Math.max(0, node.curbHeight) : 0 + const parts: THREE.BufferGeometry[] = [] + const box = (x: number, y: number, z: number, px: number, py: number, pz: number) => { + const g = new THREE.BoxGeometry(x, y, z) + g.translate(px, py, pz) + parts.push(g) } - setupGeo(outerBox) - setupGeo(innerBox) - - let frameGeo: THREE.BufferGeometry - try { - const outerBrush = new Brush(outerBox, visibleDummyMat as unknown as THREE.MeshStandardMaterial) - prepareBrushForCSG(outerBrush) - const innerBrush = new Brush(innerBox, visibleDummyMat as unknown as THREE.MeshStandardMaterial) - prepareBrushForCSG(innerBrush) - const result = csgEvaluator.evaluate(outerBrush, innerBrush, SUBTRACTION) as Brush - frameGeo = csgGeometry(result).clone() - const ic = frameGeo.getIndex()?.count ?? 0 - frameGeo.clearGroups() - if (ic > 0) frameGeo.addGroup(0, ic, 0) - outerBox.dispose() - innerBox.dispose() - result.geometry.dispose() - } catch (e) { - // eslint-disable-next-line no-console - console.error('Skylight frame CSG failed:', e) - outerBox.dispose() - innerBox.dispose() - return null + const ring = (border: number, height: number, y: number) => { + box(border, height, d + 2 * border, -w / 2 - border / 2, y, 0) + box(border, height, d + 2 * border, w / 2 + border / 2, y, 0) + box(w, height, border, 0, y, -d / 2 - border / 2) + box(w, height, border, 0, y, d / 2 + border / 2) } - - frameGeo.translate(0, -totalDepth / 2 + curbH, 0) - - return frameGeo + ring(ft, fd + curb, (curb - fd) / 2) + // A thin apron covers the roof cut edge; the raised bead catches light. + ring(ft + 0.06, 0.008, 0.004) + ring(ft + 0.008, 0.012, curb + 0.006) + const geometry = mergeGeometries(parts, false) + for (const part of parts) part.dispose() + if (geometry) { + const pos = geometry.getAttribute('position'), + normal = geometry.getAttribute('normal'), + uv = geometry.getAttribute('uv') + for (let i = 0; i < pos.count; i++) { + if (Math.abs(normal.getY(i)) > 0.5) uv.setXY(i, pos.getX(i), pos.getZ(i)) + else if (Math.abs(normal.getX(i)) > 0.5) uv.setXY(i, pos.getZ(i), pos.getY(i)) + else uv.setXY(i, pos.getX(i), pos.getY(i)) + } + geometry.setAttribute('uv2', uv.clone()) + } + return geometry } diff --git a/packages/nodes/src/skylight/geometry.ts b/packages/nodes/src/skylight/geometry.ts index 72dc04b976..c809730d2c 100644 --- a/packages/nodes/src/skylight/geometry.ts +++ b/packages/nodes/src/skylight/geometry.ts @@ -119,9 +119,31 @@ export function buildLanternGlassGeometry( ? [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] : [0, 1, 2, 0, 2, 3, 4, 5, 6, 4, 6, 7, 8, 9, 10, 8, 10, 11, 12, 13, 14, 12, 14, 15] + if (resolvedTopScale > 1e-4) { + const start = positions.length / 3 + positions.push( + -topHalfW, + topY, + topHalfD, + topHalfW, + topY, + topHalfD, + topHalfW, + topY, + -topHalfD, + -topHalfW, + topY, + -topHalfD, + ) + indices.push(start, start + 1, start + 2, start, start + 2, start + 3) + } const geometry = new THREE.BufferGeometry() geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) geometry.setIndex(indices) geometry.computeVertexNormals() + const uv: number[] = [] + for (let i = 0; i < positions.length; i += 3) uv.push(positions[i]!, positions[i + 2]!) + geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2)) + geometry.setAttribute('uv2', geometry.getAttribute('uv').clone()) return geometry } diff --git a/packages/nodes/src/skylight/index.ts b/packages/nodes/src/skylight/index.ts index 7da6afd211..0f88a86484 100644 --- a/packages/nodes/src/skylight/index.ts +++ b/packages/nodes/src/skylight/index.ts @@ -1,4 +1,6 @@ export { skylightDefinition } from './definition' export { buildFrameGeometry } from './frame-csg' -export { buildLanternGlassGeometry } from './geometry' +export { buildLanternGlassGeometry, clamp01, paneSize } from './geometry' + +export { resolveSkylightPlacement } from './placement' export { SkylightNode } from './schema' diff --git a/packages/nodes/src/skylight/move-tool.tsx b/packages/nodes/src/skylight/move-tool.tsx index ddc88ff25d..d1011ca0fb 100644 --- a/packages/nodes/src/skylight/move-tool.tsx +++ b/packages/nodes/src/skylight/move-tool.tsx @@ -11,44 +11,50 @@ import { useScene, } from '@pascal-app/core' import { + commitFreshPlacementSubtree, consumePlacementDragRelease, + isGridSnapActive, + isMagneticSnapActive, markToolCancelConsumed, + stripPlacementMetadataFlags, triggerSFX, useEditor, } from '@pascal-app/editor' -import { useCallback, useEffect, useRef, useState } from 'react' +import { getRoofOuterSurfaceFrameAtPoint } from '@pascal-app/viewer' +import { useCallback, useEffect, useState } from 'react' import * as THREE from 'three' import { createRelativeRoofDrag, type RelativeRoofDragTarget, roofSegmentLocalToBuildingLocal, - snapRelativeRoofDragTarget, } from '../shared/relative-roof-drag' -import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' +import { surfaceQuatFromNormal } from '../shared/roof-surface' import { clearRoofSurfacePlacementGuides, publishRoofSurfaceNodePlacementGuides, snapRoofSurfaceNodeTarget, } from '../shared/roof-surface-placement-guides' +import { resolveSkylightPlacement } from './placement' import SkylightPreview from './preview' +/** + * Skylight move tool. Mirrors the placement tool's cursor behaviour + * (ghost follows the roof surface; click on a roof commits) but for an + * already-existing skylight: 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 MoveSkylightTool({ node }: { node: SkylightNode }) { const exitMoveMode = useCallback(() => { useEditor.getState().setMovingNode(null) }, []) - const previewRef = useRef(null!) - const [previewPos, setPreviewPos] = useState<[number, number, number]>([0, 0, 0]) - // Mirror the placement tool's transform stack so the ghost reads the - // same in both flows: outer rotation-y aligns the segment's world yaw - // (roof + segment), inner quaternion tilts to the segment surface in - // segment-local space (analytical, not raycast — raycast normals can - // be flipped or in the wrong frame depending on hit-object state). - // The skylight's own `rotation` is applied on a deeper group so it - // stays editable on top of the surface alignment. - const [previewYaw, setPreviewYaw] = useState(0) + const [previewPos, setPreviewPos] = useState<[number, number, number] | null>(null) const [previewSurfaceQuat, setPreviewSurfaceQuat] = useState(null) - const [hasHit, setHasHit] = useState(false) + const [previewValid, setPreviewValid] = useState(true) + const [previewYaw, setPreviewYaw] = useState(0) useEffect(() => { useScene.temporal.getState().pause() @@ -60,58 +66,87 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) { parentId: node.parentId, metadata: node.metadata, } - const meta = typeof node.metadata === 'object' && node.metadata !== null ? (node.metadata as Record) : {} const isNew = !!meta.isNew - useScene.getState().updateNode(node.id as AnyNodeId, { - metadata: { ...meta, isTransient: true }, - }) - const skylightObj = sceneRegistry.nodes.get(node.id) - if (skylightObj) skylightObj.visible = false + const ventObj = sceneRegistry.nodes.get(node.id) + if (ventObj) ventObj.visible = false - let lastSnapX = 0 - let lastSnapZ = 0 + let lastSnap: [number, number] | null = null let lastTarget: RelativeRoofDragTarget | null = null let committed = false + let forcePlacement = false + const roofOffset = 0 const roofDrag = createRelativeRoofDrag(original) const clearTarget = () => { lastTarget = null - setHasHit(false) + lastSnap = null + setPreviewPos(null) + setPreviewSurfaceQuat(null) clearRoofSurfacePlacementGuides() } const resolveSnappedTarget = (event: RoofEvent): RelativeRoofDragTarget | null => { const rawTarget = roofDrag.resolve(event) if (!rawTarget) return null - return snapRoofSurfaceNodeTarget({ - target: snapRelativeRoofDragTarget(rawTarget, event.nativeEvent?.shiftKey === true), + forcePlacement = event.nativeEvent?.altKey === true + let placement = resolveSkylightPlacement( + rawTarget.segment, node, - bypass: event.nativeEvent?.shiftKey === true, - }) + rawTarget.localX, + rawTarget.localZ, + { + roofOffset, + gridStep: !forcePlacement && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0, + }, + ) + let target = { + ...rawTarget, + localX: placement.position[0], + localY: placement.position[1], + localZ: placement.position[2], + } + if (!forcePlacement && isMagneticSnapActive()) { + target = snapRoofSurfaceNodeTarget({ target, node }) + placement = resolveSkylightPlacement(target.segment, node, target.localX, target.localZ, { + roofOffset, + }) + target = { ...target, localY: placement.position[1] } + } + setPreviewValid(placement.valid || forcePlacement) + return target } - // Resolve which segment the cursor is over, then derive the same - // preview transform stack the placement tool uses (`skylight/tool.tsx`): - // analytical surface normal in segment-local frame → outer yaw = - // roof + segment rotation. Falls back to leaving the preview hidden - // if the cursor is between segments — the placement tool does the - // same via its `if (!hit) return` guard. - const updateFromHit = (event: RoofEvent) => { - const roof = event.node as RoofNode + const updatePreview = (event: RoofEvent) => { const target = resolveSnappedTarget(event) if (!target) { clearTarget() - return false + return } lastTarget = target - const normal = getAnalyticalNormal(target.localX, target.localZ, target.segment) + + const sx = Math.round(target.localX * 20) / 20 + const sz = Math.round(target.localZ * 20) / 20 + if ( + !forcePlacement && + (isGridSnapActive() || isMagneticSnapActive()) && + (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) + ) { + triggerSFX('sfx:grid-snap') + lastSnap = [sx, sz] + } + + const normal = getRoofOuterSurfaceFrameAtPoint( + target.segment, + target.localX, + target.localZ, + ).normal setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion())) - setPreviewYaw((roof.rotation ?? 0) + (target.segment.rotation ?? 0)) + setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0)) setPreviewPos( roofSegmentLocalToBuildingLocal(target.segment.id, [ target.localX, @@ -119,82 +154,49 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) { target.localZ, ]), ) - setHasHit(true) publishRoofSurfaceNodePlacementGuides({ - roof, + roof: event.node as RoofNode, segment: target.segment, center: [target.localX, target.localY, target.localZ], node, }) - return true - } - - const onRoofMove = (event: RoofEvent) => { - const sx = Math.round(event.position[0] * 20) / 20 - const sz = Math.round(event.position[2] * 20) / 20 - if (event.nativeEvent?.shiftKey !== true && (sx !== lastSnapX || sz !== lastSnapZ)) { - triggerSFX('sfx:grid-snap') - lastSnapX = sx - lastSnapZ = sz - } - updateFromHit(event) - event.stopPropagation() - } - - const onRoofEnter = (event: RoofEvent) => { - updateFromHit(event) event.stopPropagation() } const onRoofClick = (event: RoofEvent) => { if (committed) return - const st = useScene.getState() - - const target = lastTarget ?? resolveSnappedTarget(event) + const target = event.node ? resolveSnappedTarget(event) : lastTarget if (!target) return + if ( + !forcePlacement && + !resolveSkylightPlacement(target.segment, node, target.localX, target.localZ, { + roofOffset, + }).valid + ) + return committed = true - const targetSegmentId = target.segment.id as AnyNodeId - const finalRotation = original.rotation - - st.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, - }) - useScene.temporal.getState().resume() + const st = useScene.getState() - st.updateNode(node.id as AnyNodeId, { + const patch = { roofSegmentId: targetSegmentId, parentId: targetSegmentId, - position: [target.localX, target.localY, target.localZ], - rotation: finalRotation, + position: [target.localX, target.localY, target.localZ] as [number, number, number], + rotation: original.rotation, visible: true, - metadata: {}, - }) - - if (original.roofSegmentId && original.roofSegmentId !== (targetSegmentId as string)) { - const oldSeg = st.nodes[original.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined - if (oldSeg) { - st.updateNode(original.roofSegmentId as AnyNodeId, { - children: (oldSeg.children ?? []).filter((id) => id !== node.id), - }) - } - 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(original.roofSegmentId as AnyNodeId) + metadata: stripPlacementMetadataFlags(original.metadata) as SkylightNode['metadata'], + } + if (isNew) { + commitFreshPlacementSubtree(node.id as AnyNodeId, patch) + } else { + useScene.temporal.getState().resume() + st.updateNode(node.id as AnyNodeId, patch) + useScene.temporal.getState().pause() } + st.dirtyNodes.add(targetSegmentId) st.dirtyNodes.add(node.id as AnyNodeId) - useScene.temporal.getState().pause() - const obj = sceneRegistry.nodes.get(node.id) if (obj) obj.visible = true @@ -206,19 +208,19 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) { const onCancel = () => { if (isNew) { - useScene.temporal.getState().resume() - const parentId = original.roofSegmentId + // Freshly-cloned skylight — 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 AnyNodeId] as - | RoofSegmentNode - | undefined + const parent = useScene.getState().nodes[parentId] as RoofSegmentNode | undefined if (parent) { - useScene.getState().updateNode(parentId as AnyNodeId, { + useScene.getState().updateNode(parentId, { children: (parent.children ?? []).filter((id) => id !== node.id), }) } } useScene.getState().deleteNode(node.id as AnyNodeId) + useScene.temporal.getState().resume() markToolCancelConsumed() clearRoofSurfacePlacementGuides() exitMoveMode() @@ -235,7 +237,6 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) { if (original.roofSegmentId) { useScene.getState().dirtyNodes.add(original.roofSegmentId as AnyNodeId) } - const obj = sceneRegistry.nodes.get(node.id) if (obj) obj.visible = true @@ -254,21 +255,24 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) { } as unknown as RoofEvent) } - emitter.on('roof:move', onRoofMove) - emitter.on('roof:enter', onRoofEnter) + emitter.on('roof:move', updatePreview) + emitter.on('roof:enter', updatePreview) emitter.on('roof:click', onRoofClick) emitter.on('roof:leave', clearTarget) emitter.on('tool:cancel', onCancel) window.addEventListener('pointerup', onPlacementDragPointerUp) return () => { - emitter.off('roof:move', onRoofMove) - emitter.off('roof:enter', onRoofEnter) + emitter.off('roof:move', updatePreview) + emitter.off('roof:enter', updatePreview) emitter.off('roof:click', onRoofClick) emitter.off('roof:leave', clearTarget) emitter.off('tool:cancel', onCancel) window.removeEventListener('pointerup', onPlacementDragPointerUp) + // 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 clearRoofSurfacePlacementGuides() @@ -276,15 +280,13 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) { } }, [exitMoveMode, node]) - if (!previewSurfaceQuat) return null + if (!(previewPos && previewSurfaceQuat)) return null return ( - + - - - + diff --git a/packages/nodes/src/skylight/panel.tsx b/packages/nodes/src/skylight/panel.tsx index 754452f133..fd834eb2a4 100644 --- a/packages/nodes/src/skylight/panel.tsx +++ b/packages/nodes/src/skylight/panel.tsx @@ -89,15 +89,6 @@ export default function SkylightPanel() { 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) @@ -122,13 +113,15 @@ export default function SkylightPanel() { const computeWorldPos = () => { if (!skylightObj) return { x: 0, z: 0 } - const localPt = new Vector3(node.position[0] ?? 0, 0, node.position[2] ?? 0) + const localPt = new Vector3() const worldPt = localPt.applyMatrix4(skylightObj.matrixWorld) return { x: worldPt.x, z: worldPt.z } } const computeWorldRotation = () => { if (!skylightObj) return node.rotation ?? 0 - const m = skylightObj.matrixWorld.elements + const host = segment && sceneRegistry.nodes.get(segment.id) + host?.updateWorldMatrix(true, false) + const m = (host ?? skylightObj).matrixWorld.elements const ancestorWorldY = Math.atan2(-(m[2] ?? 0), m[0] ?? 1) return ancestorWorldY + (node.rotation ?? 0) } @@ -142,7 +135,7 @@ export default function SkylightPanel() { const state = useScene.getState() const worldPt = new Vector3(wx, 0, wz) for (const candidate of Object.values(state.nodes)) { - if (candidate?.type !== 'roof-segment') continue + if (candidate?.type !== 'roof-segment' || candidate.parentId !== segment?.parentId) continue const seg = candidate as RoofSegmentNode const segObj = sceneRegistry.nodes.get(seg.id) if (!segObj) continue @@ -224,7 +217,9 @@ export default function SkylightPanel() { const commitWorldRotation = (newWorldRot: number) => { if (!skylightObj) return - const m = skylightObj.matrixWorld.elements + const host = segment && sceneRegistry.nodes.get(segment.id) + host?.updateWorldMatrix(true, false) + const m = (host ?? skylightObj).matrixWorld.elements const ancestorWorldY = Math.atan2(-(m[2] ?? 0), m[0] ?? 1) commitProp({ rotation: newWorldRot - ancestorWorldY }) } @@ -235,8 +230,6 @@ export default function SkylightPanel() { const preset = SKYLIGHT_TYPE_PRESETS[skylightType] commitProp({ skylightType, - width: preset.width, - height: preset.height, frameThickness: preset.frameThickness, frameDepth: preset.frameDepth, glassThickness: preset.glassThickness, @@ -258,28 +251,34 @@ export default function SkylightPanel() { return ( - +
{SKYLIGHT_TYPE_ORDER.map((skylightType) => { const isSelected = activeSkylightType === skylightType return (
previewProp({ glassThickness: v })} onCommit={(v) => commitProp({ glassThickness: v })} @@ -303,7 +301,6 @@ export default function SkylightPanel() { <> previewProp({ lanternHeight: v })} onCommit={(v) => commitProp({ lanternHeight: v })} @@ -331,15 +328,15 @@ export default function SkylightPanel() { <> previewProp({ operationState: v })} - onCommit={(v) => commitProp({ operationState: v })} - precision={2} + onChange={(v) => previewProp({ operationState: v / 100 })} + onCommit={(v) => commitProp({ operationState: v / 100 })} + precision={0} restoreOnCommit={false} - step={0.01} - unit="" - value={Math.round((node.operationState ?? 0) * 100) / 100} + step={1} + unit="%" + value={Math.round((node.operationState ?? 0) * 100)} /> previewProp({ motorHousingSize: v })} onCommit={(v) => commitProp({ motorHousingSize: v })} @@ -391,15 +387,15 @@ export default function SkylightPanel() { <> previewProp({ operationState: v })} - onCommit={(v) => commitProp({ operationState: v })} - precision={2} + onChange={(v) => previewProp({ operationState: v / 100 })} + onCommit={(v) => commitProp({ operationState: v / 100 })} + precision={0} restoreOnCommit={false} - step={0.01} - unit="" - value={Math.round((node.operationState ?? 0) * 100) / 100} + step={1} + unit="%" + value={Math.round((node.operationState ?? 0) * 100)} /> commitProp({ slideDirection: v as SkylightNode['slideDirection'] })} @@ -411,7 +407,6 @@ export default function SkylightPanel() { /> previewProp({ trackWidth: v })} onCommit={(v) => commitProp({ trackWidth: v })} @@ -428,7 +423,6 @@ export default function SkylightPanel() { previewProp({ width: v })} onCommit={(v) => commitProp({ width: v })} @@ -439,8 +433,7 @@ export default function SkylightPanel() { value={node.width} /> previewProp({ height: v })} onCommit={(v) => commitProp({ height: v })} @@ -455,7 +448,6 @@ export default function SkylightPanel() { previewProp({ frameThickness: v })} onCommit={(v) => commitProp({ frameThickness: v })} @@ -467,7 +459,6 @@ export default function SkylightPanel() { /> previewProp({ frameDepth: v })} onCommit={(v) => commitProp({ frameDepth: v })} @@ -479,7 +470,6 @@ export default function SkylightPanel() { /> previewProp({ cutoutOffset: v })} onCommit={(v) => commitProp({ cutoutOffset: v })} @@ -503,7 +493,6 @@ export default function SkylightPanel() { {(node.curb ?? false) && ( previewProp({ curbHeight: v })} onCommit={(v) => commitProp({ curbHeight: v })} @@ -516,7 +505,7 @@ export default function SkylightPanel() { )} - + { const newWorldRot = (degrees * Math.PI) / 180 if (!skylightObj) return - const m = skylightObj.matrixWorld.elements + const host = segment && sceneRegistry.nodes.get(segment.id) + host?.updateWorldMatrix(true, false) + const m = (host ?? skylightObj).matrixWorld.elements const ancestorWorldY = Math.atan2(-(m[2] ?? 0), m[0] ?? 1) previewProp({ rotation: newWorldRot - ancestorWorldY }) }} diff --git a/packages/nodes/src/skylight/placement.test.ts b/packages/nodes/src/skylight/placement.test.ts new file mode 100644 index 0000000000..1f8fe138f1 --- /dev/null +++ b/packages/nodes/src/skylight/placement.test.ts @@ -0,0 +1,41 @@ +import { expect, test } from 'bun:test' +import { RoofSegmentNode } from '@pascal-app/core' +import { getRoofOuterSurfaceFrameAtPoint } from '@pascal-app/viewer' +import { resolveSkylightPlacement, SkylightNode } from './index' + +const segment = RoofSegmentNode.parse({ width: 8, depth: 6, roofType: 'gable' }) +const skylight = SkylightNode.parse({ rotation: 0.2 }) + +test('placement snaps on the finished roof while preserving rotation', () => { + const placed = resolveSkylightPlacement(segment, skylight, 0.13, 1.37, { gridStep: 0.25 }) + expect(placed.position[0]).toBe(0.25) + expect(placed.position[2]).toBe(1.25) + expect(placed.rotation).toBe(0.2) + expect(placed.position[1]).toBeCloseTo( + getRoofOuterSurfaceFrameAtPoint(segment, 0.25, 1.25).point.y, + ) + expect(placed.valid).toBe(true) + expect(resolveSkylightPlacement(segment, skylight, 0.13, 1.37).position[0]).toBe(0.13) +}) + +test('the glass and flashing must fit inside one roof face and trimmed edges', () => { + expect(resolveSkylightPlacement(segment, skylight, 0, 0).valid).toBe(false) + expect(resolveSkylightPlacement(segment, skylight, 0, 3.5).valid).toBe(false) + const trimmed = RoofSegmentNode.parse({ ...segment, trim: { right: 2 } }) + expect(resolveSkylightPlacement(trimmed, skylight, 1.9, 1.5).valid).toBe(false) + expect(resolveSkylightPlacement(trimmed, skylight, 1, 1.5).valid).toBe(true) + const flat = RoofSegmentNode.parse({ + roofType: 'flat', + width: 8, + depth: 6, + overhang: 0, + gableOverhang: 0, + wallThickness: 0, + }) + expect( + resolveSkylightPlacement(flat, { ...skylight, rotation: 0, width: 0.5 }, 3.6, 0).valid, + ).toBe(true) + expect(resolveSkylightPlacement(flat, { ...skylight, rotation: 0, width: 1 }, 3.6, 0).valid).toBe( + false, + ) +}) diff --git a/packages/nodes/src/skylight/placement.ts b/packages/nodes/src/skylight/placement.ts new file mode 100644 index 0000000000..69ae19c258 --- /dev/null +++ b/packages/nodes/src/skylight/placement.ts @@ -0,0 +1,49 @@ +import { difference, type Ring, type RoofSegmentNode, type SkylightNode } from '@pascal-app/core' +import { getRoofOuterSurfaceFrameAtPoint } from '@pascal-app/viewer' +import * as THREE from 'three' +import { roofFootprintInsideTrim } from '../shared/roof-footprint-trim' +import { + getDownSlopeYaw, + getRoofSurfaceFaceBoundsAt, + surfaceQuatFromNormal, +} from '../shared/roof-surface' + +export function resolveSkylightPlacement( + segment: RoofSegmentNode, + node: SkylightNode, + x: number, + z: number, + options: { gridStep?: number; roofOffset?: number; alignDownslope?: boolean } = {}, +) { + const step = options.gridStep ?? 0 + if (step > 0) { + x = Math.round(x / step) * step + z = Math.round(z / step) * step + } + const frame = getRoofOuterSurfaceFrameAtPoint(segment, x, z) + const rotation = options.alignDownslope ? getDownSlopeYaw(x, z, segment) : node.rotation + const quat = surfaceQuatFromNormal(frame.normal, new THREE.Quaternion()).multiply( + new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), rotation), + ) + const hw = + Math.max(0.02, node.width) / 2 + node.frameThickness + Math.max(0.06, node.cutoutOffset) + const hd = + Math.max(0.02, node.height) / 2 + node.frameThickness + Math.max(0.06, node.cutoutOffset) + const footprint: Ring = [ + [-hw, -hd], + [hw, -hd], + [hw, hd], + [-hw, hd], + ].map(([px, pz]) => { + const point = new THREE.Vector3(px, 0, pz).applyQuaternion(quat) + return [x + point.x, z + point.z] + }) + const face = getRoofSurfaceFaceBoundsAt(segment, x, z).polygon + return { + position: [x, frame.point.y + (options.roofOffset ?? 0), z] as [number, number, number], + rotation, + valid: + roofFootprintInsideTrim(segment, footprint) && + difference(footprint, face, { throwOnError: true }).length === 0, + } +} diff --git a/packages/nodes/src/skylight/preview.tsx b/packages/nodes/src/skylight/preview.tsx index 09807c8357..b89fbd633b 100644 --- a/packages/nodes/src/skylight/preview.tsx +++ b/packages/nodes/src/skylight/preview.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo } from 'react' import * as THREE from 'three' import { INVALID_GHOST_COLOR } from '../shared/ghost-materials' import { buildFrameGeometry } from './frame-csg' +import { HingedGlass, LanternGlass, SlidingGlass } from './renderer' import type { SkylightNode } from './schema' const ghostMaterial = new THREE.MeshStandardMaterial({ @@ -49,20 +50,43 @@ const SkylightPreview = ({ node, invalid }: { node: SkylightNode; invalid?: bool return g }, [node.width, node.height, node.glassThickness, node.curb, node.curbHeight]) - useEffect( - () => () => { - frame?.dispose() - glass.dispose() - }, - [frame, glass], - ) + useEffect(() => () => frame?.dispose(), [frame]) + useEffect(() => () => glass.dispose(), [glass]) if (!frame) return null return ( - + {}} /> - {}} /> + {node.skylightType === 'lantern' ? ( + + ) : node.skylightType === 'opening' ? ( + + ) : node.skylightType === 'sliding' ? ( + + ) : ( + {}} /> + )} ) } diff --git a/packages/nodes/src/skylight/renderer.tsx b/packages/nodes/src/skylight/renderer.tsx index f6fe5f7ff8..f1f11eb119 100644 --- a/packages/nodes/src/skylight/renderer.tsx +++ b/packages/nodes/src/skylight/renderer.tsx @@ -210,7 +210,7 @@ function PaneFrame({ ) } -function LanternGlass({ +export function LanternGlass({ curbHeight, frameMaterial, glassMaterial, @@ -399,7 +399,7 @@ function ElectricMotorHousing({ ) } -function HingedGlass({ +export function HingedGlass({ curbHeight, frameMaterial, glassMaterial, @@ -518,7 +518,7 @@ function HingedGlass({ ) } -function SlidingGlass({ +export function SlidingGlass({ curbHeight, frameMaterial, glassMaterial, diff --git a/packages/nodes/src/skylight/tool.tsx b/packages/nodes/src/skylight/tool.tsx index c23fbfc37b..5a3ec080fe 100644 --- a/packages/nodes/src/skylight/tool.tsx +++ b/packages/nodes/src/skylight/tool.tsx @@ -1,159 +1,160 @@ 'use client' +import { type AnyNodeId, emitter, type RoofEvent, SkylightNode, useScene } from '@pascal-app/core' import { - type AnyNodeId, - emitter, - type RoofEvent, - type RoofNode, - SkylightNode, - sceneRegistry, - useScene, -} from '@pascal-app/core' -import { triggerSFX, usePlacementPreview } from '@pascal-app/editor' -import { useViewer } from '@pascal-app/viewer' -import { useEffect, useMemo, useRef, useState } from 'react' + isGridSnapActive, + isMagneticSnapActive, + triggerSFX, + useEditor, + usePlacementPreview, +} from '@pascal-app/editor' +import { getRoofOuterSurfaceFrameAtPoint, useViewer } from '@pascal-app/viewer' +import { useEffect, useMemo, useState } from 'react' import * as THREE from 'three' +import { roofSegmentLocalToBuildingLocal } from '../shared/relative-roof-drag' import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' -import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' +import { surfaceQuatFromNormal } from '../shared/roof-surface' import { clearRoofSurfacePlacementGuides, - publishRoofSurfacePlacementGuides, - roofSurfaceFootprintFromNode, + publishRoofSurfaceNodePlacementGuides, + snapRoofSurfaceNodeTarget, } from '../shared/roof-surface-placement-guides' import { skylightDefinition } from './definition' +import { resolveSkylightPlacement } from './placement' import SkylightPreview from './preview' -const worldPoint = new THREE.Vector3() - -const SkylightTool = () => { +export default function SkylightTool() { const activeBuildingId = useViewer((s) => s.selection.buildingId) - const setSelection = useViewer((s) => s.setSelection) - - const [previewPos, setPreviewPos] = useState<[number, number, number] | null>(null) - const [previewYaw, setPreviewYaw] = useState(0) - const [previewSurfaceQuat, setPreviewSurfaceQuat] = useState(null) - const lastSnapRef = useRef<[number, number] | null>(null) - - const previewNode = useMemo( - () => - SkylightNode.parse({ - ...skylightDefinition.defaults(), - name: 'Skylight', - position: [0, 0, 0], - rotation: 0, - }), + const seed = useMemo( + () => SkylightNode.parse({ ...skylightDefinition.defaults(), name: 'Skylight' }), [], ) + const [preview, setPreview] = useState<{ + node: SkylightNode + position: [number, number, number] + normal: THREE.Quaternion + yaw: number + valid: boolean + } | null>(null) 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 clear = () => { + setPreview(null) + if (usePlacementPreview.getState().node?.id === seed.id) + usePlacementPreview.getState().clear() + clearRoofSurfacePlacementGuides() } - - 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 (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== sx || prev[1] !== sz)) { - triggerSFX('sfx:grid-snap') - lastSnapRef.current = [sx, sz] + const resolve = (event: RoofEvent) => { + const hit = resolveRoofSegmentHit(event.node, ...event.position) + if (!hit) return null + const force = event.nativeEvent?.altKey === true + let placement = resolveSkylightPlacement(hit.segment, seed, hit.localX, hit.localZ, { + gridStep: !force && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0, + }) + if (!force && isMagneticSnapActive()) { + const target = snapRoofSurfaceNodeTarget({ + target: { + ...hit, + hit, + localX: placement.position[0], + localY: placement.position[1], + localZ: placement.position[2], + }, + node: { ...seed, rotation: placement.rotation }, + }) + placement = resolveSkylightPlacement(hit.segment, seed, target.localX, target.localZ, {}) } - - 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)) - usePlacementPreview.getState().set( - SkylightNode.parse({ - ...previewNode, - parentId: hit.segment.id, - position: [hit.localX, hit.localY, hit.localZ], - roofSegmentId: hit.segment.id, - }), + return { hit, placement, force } + } + const show = (event: RoofEvent) => { + const resolved = resolve(event) + if (!resolved) { + clear() + return + } + const { hit, placement, force } = resolved + const node = { + ...seed, + position: placement.position, + rotation: placement.rotation, + parentId: hit.segment.id, + roofSegmentId: hit.segment.id, + } + const normal = getRoofOuterSurfaceFrameAtPoint( hit.segment, - ) - publishRoofSurfacePlacementGuides({ - roof: event.node as RoofNode, + placement.position[0], + placement.position[2], + ).normal + setPreview({ + node, + position: roofSegmentLocalToBuildingLocal(hit.segment.id, placement.position), + normal: surfaceQuatFromNormal(normal, new THREE.Quaternion()), + yaw: event.node.rotation + hit.segment.rotation, + valid: placement.valid || force, + }) + usePlacementPreview.getState().set(node, hit.segment) + publishRoofSurfaceNodePlacementGuides({ + roof: event.node, segment: hit.segment, - center: [hit.localX, hit.localY, hit.localZ], - footprint: roofSurfaceFootprintFromNode(previewNode), + center: placement.position, + node, }) 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 skylight = SkylightNode.parse({ - ...skylightDefinition.defaults(), - name: 'Skylight', + const place = (event: RoofEvent) => { + const resolved = resolve(event) + if (!resolved) return + const { hit, placement, force } = resolved + if (!placement.valid && !force) { + show(event) + return + } + const node = SkylightNode.parse({ + ...seed, + id: undefined, roofSegmentId: hit.segment.id, - position: [hit.localX, hit.localY, hit.localZ], - rotation: 0, + position: placement.position, + rotation: placement.rotation, }) - state.createNode(skylight, hit.segment.id as AnyNodeId) - state.dirtyNodes.add(hit.segment.id as AnyNodeId) - setSelection({ selectedIds: [skylight.id] }) + useScene.getState().createNode(node, hit.segment.id as AnyNodeId) + useViewer.getState().setSelection({ selectedIds: [node.id] }) triggerSFX('sfx:item-place') - const placementPreview = usePlacementPreview.getState() - if (placementPreview.node?.id === previewNode.id) placementPreview.clear() - clearRoofSurfacePlacementGuides() + clear() event.stopPropagation() } - - emitter.on('roof:move', updatePreview) - emitter.on('roof:enter', updatePreview) - emitter.on('roof:click', onClick) - + emitter.on('roof:move', show) + emitter.on('roof:enter', show) + emitter.on('roof:leave', clear) + emitter.on('roof:click', place) + emitter.on('tool:cancel', clear) return () => { - emitter.off('roof:move', updatePreview) - emitter.off('roof:enter', updatePreview) - emitter.off('roof:click', onClick) - const placementPreview = usePlacementPreview.getState() - if (placementPreview.node?.id === previewNode.id) placementPreview.clear() - clearRoofSurfacePlacementGuides() + emitter.off('roof:move', show) + emitter.off('roof:enter', show) + emitter.off('roof:leave', clear) + emitter.off('roof:click', place) + emitter.off('tool:cancel', clear) + clear() } - }, [activeBuildingId, setSelection, previewNode]) + }, [activeBuildingId, seed]) return ( <> } + ghost={} onInvalidTarget={() => { - setPreviewPos(null) - setPreviewSurfaceQuat(null) - const placementPreview = usePlacementPreview.getState() - if (placementPreview.node?.id === previewNode.id) placementPreview.clear() + setPreview(null) + usePlacementPreview.getState().clear() clearRoofSurfacePlacementGuides() }} /> - {activeBuildingId && previewPos && previewSurfaceQuat && ( - - - - + {preview && ( + + + + @@ -161,5 +162,3 @@ const SkylightTool = () => { ) } - -export default SkylightTool diff --git a/packages/nodes/src/solar-panel/__tests__/geometry.test.ts b/packages/nodes/src/solar-panel/__tests__/geometry.test.ts index f617c50a01..866a12c6a5 100644 --- a/packages/nodes/src/solar-panel/__tests__/geometry.test.ts +++ b/packages/nodes/src/solar-panel/__tests__/geometry.test.ts @@ -1,7 +1,30 @@ import { describe, expect, test } from 'bun:test' -import { getActiveRoofHeight, type RoofSegmentNode } from '@pascal-app/core' -import { getAnalyticalNormal, getSurfaceY } from '../../shared/roof-surface' -import { buildSolarPanelGeometry, computeAutoFit, flippedPanelDims } from '../geometry' +import { + BoxVentNode, + difference, + getActiveRoofHeight, + intersection, + nodeRegistry, + type RoofSegmentNode, + registerNode, + SkylightNode, +} from '@pascal-app/core' +import { boxVentDefinition } from '../../box-vent' +import { + getAnalyticalNormal, + getRoofSurfaceFaceBoundsAt, + getSurfaceY, +} from '../../shared/roof-surface' +import { skylightDefinition } from '../../skylight' +import { + buildSolarPanelGeometry, + computeAutoFit, + flippedPanelDims, + resolveSolarPanelPlacement, + solarPanelDefinition, + solarPanelFootprint, + solarPanelObstacles, +} from '../index' import { SolarPanelNode } from '../schema' // atan(2 / 3) in degrees — gives `getActiveRoofHeight` ≈ 2.0 on the @@ -165,7 +188,7 @@ describe('getAnalyticalNormal', () => { describe('computeAutoFit', () => { test('default panel + 8m × 6m gable fits a sensible grid', () => { - const fit = computeAutoFit(fixtureSegment(), SolarPanelNode.parse({}))! + const fit = computeAutoFit(fixtureSegment(), SolarPanelNode.parse({ position: [0, 0, 1.5] }))! expect(fit.rows).toBeGreaterThanOrEqual(1) expect(fit.columns).toBeGreaterThanOrEqual(1) expect(fit.rows).toBeLessThanOrEqual(20) @@ -188,3 +211,176 @@ describe('flippedPanelDims', () => { }) }) }) + +describe('solar auto-fit placement constraints', () => { + const panelAt = (position: [number, number, number], rotation = 0) => + SolarPanelNode.parse({ + position, + rotation, + panelWidth: 1, + panelHeight: 1, + gapX: 0, + gapY: 0, + standoffHeight: 0, + frameDepth: 0.02, + }) + + test('fits at the current offset rather than using the entire roof width', () => { + const segment = fixtureSegment({ + roofType: 'flat', + width: 8, + depth: 6, + overhang: 0, + wallThickness: 0, + }) + const centered = computeAutoFit(segment, panelAt([0, 0, 0]))! + const offset = computeAutoFit(segment, panelAt([3, 0, 0]))! + expect(offset.columns).toBeLessThan(centered.columns) + expect(offset.columns).toBe(2) + expect(computeAutoFit(segment, panelAt([10, 0, 0]))).toBeNull() + }) + + test('rotation changes how many rectangular modules fit', () => { + const segment = fixtureSegment({ + roofType: 'flat', + width: 8, + depth: 3, + overhang: 0, + wallThickness: 0, + }) + const panel = SolarPanelNode.parse({ ...panelAt([0, 0, 0]), panelWidth: 2, panelHeight: 1 }) + expect(computeAutoFit(segment, panel)).toEqual({ rows: 3, columns: 4 }) + expect(computeAutoFit(segment, { ...panel, rotation: Math.PI / 2 })).toEqual({ + rows: 8, + columns: 1, + }) + }) + + test('uses slope length on both gable faces and refuses to straddle the ridge', () => { + const segment = fixtureSegment({ pitch: 60, overhang: 0, wallThickness: 0 }) + for (const z of [-1.5, 1.5]) { + const panel = panelAt([0, 0, z]) + const fit = computeAutoFit(segment, panel)! + expect(fit.rows).toBeGreaterThan(3) + const footprint = solarPanelFootprint(segment, { ...panel, ...fit }) + expect(footprint.every((point) => Math.sign(point[1]) === Math.sign(z))).toBe(true) + } + expect(computeAutoFit(segment, panelAt([0, 0, 0]))).toBeNull() + }) + + test('a rotated array stays within the triangular hip face', () => { + const segment = fixtureSegment({ roofType: 'hip' }) + const panel = panelAt([3, 0, 0], Math.PI / 5) + const fit = computeAutoFit(segment, panel)! + expect(fit).not.toBeNull() + const footprint = solarPanelFootprint(segment, { ...panel, ...fit }) + const face = getRoofSurfaceFaceBoundsAt(segment, 3, 0).polygon + expect(difference(footprint, face)).toEqual([]) + }) + + test('avoids occupied space and reports when the current center is blocked', () => { + const segment = fixtureSegment({ roofType: 'flat', width: 8, depth: 6 }) + const panel = panelAt([0, 0, 0]) + const obstacle: [number, number][] = [ + [1.2, -4], + [3, -4], + [3, 4], + [1.2, 4], + ] + const fit = computeAutoFit(segment, panel, [obstacle])! + expect(fit.columns).toBe(2) + expect(intersection(solarPanelFootprint(segment, { ...panel, ...fit }), obstacle)).toEqual([]) + expect(computeAutoFit(segment, panelAt([2, 0, 0]), [obstacle])).toBeNull() + }) + + test('respects trimmed edges and diagonal corner cuts', () => { + const segment = fixtureSegment({ roofType: 'flat', width: 8, depth: 6 }) + const trimmed = { + ...segment, + trim: { left: 0, right: 3, front: 0, back: 0 }, + } as RoofSegmentNode + expect(computeAutoFit(trimmed, panelAt([0, 0, 0]))!.columns).toBe(2) + const corner = { ...segment, trim: { frontRightX: 3, frontRightZ: 3 } } as RoofSegmentNode + expect(computeAutoFit(segment, panelAt([3, 0, 2]))).not.toBeNull() + expect(computeAutoFit(corner, panelAt([3, 0, 2]))).toBeNull() + }) + + test('keeps the schema row and column limit on a large roof', () => { + const fit = computeAutoFit( + fixtureSegment({ roofType: 'flat', width: 100, depth: 100 }), + panelAt([0, 0, 0]), + ) + expect(fit).toEqual({ rows: 20, columns: 20 }) + }) +}) + +test('auto-fit collects occupied accessory envelopes from the current roof segment', () => { + const restoreRegistry = nodeRegistry._snapshot() + try { + for (const definition of [solarPanelDefinition, boxVentDefinition, skylightDefinition]) { + if (!nodeRegistry.get(definition.kind)) registerNode(definition) + } + const segment = fixtureSegment({ roofType: 'flat' }) + const panel = SolarPanelNode.parse({ roofSegmentId: segment.id }) + const vent = BoxVentNode.parse({ roofSegmentId: segment.id, position: [2, 0, 0] }) + const skylight = SkylightNode.parse({ roofSegmentId: segment.id, position: [-2, 0, 0] }) + const otherPanel = SolarPanelNode.parse({ roofSegmentId: segment.id, position: [0, 0, 2] }) + const elsewhere = BoxVentNode.parse({ roofSegmentId: 'rseg_elsewhere' }) + const obstacles = solarPanelObstacles(segment, panel, [ + panel, + vent, + skylight, + otherPanel, + elsewhere, + ]) + expect(obstacles).toHaveLength(3) + for (const [x, z] of [ + [2, 0], + [-2, 0], + [0, 2], + ]) { + expect(computeAutoFit(segment, { ...panel, position: [x!, 0, z!] }, obstacles)).toBeNull() + } + } finally { + restoreRegistry() + } +}) + +test('tilted glass stays above its mounting plane across the entire array', () => { + for (const tiltAngle of [0, 15, 45]) { + const panel = SolarPanelNode.parse({ rows: 3, columns: 2, mountingType: 'tilted', tiltAngle }) + const geo = buildSolarPanelGeometry(panel)! + geo.rotateX((tiltAngle * Math.PI) / 180) + const group = geo.groups.find((g) => g.materialIndex === 1)! + const positions = geo.getAttribute('position') + for (let i = group.start; i < group.start + group.count; i++) { + const vertex = geo.index ? geo.index.getX(i) : i + expect(positions.getY(vertex)).toBeGreaterThan(0) + } + geo.dispose() + } +}) + +test('placement rejects roof-edge and obstacle overlap and uses the selected grid', () => { + const segment = fixtureSegment({ roofType: 'flat' }) + const panel = SolarPanelNode.parse({ rows: 1, columns: 1 }) + expect( + resolveSolarPanelPlacement(segment, panel, 0.26, 0.74, { gridStep: 0.5 }).position.filter( + (_, i) => i !== 1, + ), + ).toEqual([0.5, 0.5]) + expect(resolveSolarPanelPlacement(segment, panel, 0, 0).valid).toBe(true) + expect(resolveSolarPanelPlacement(segment, panel, 4, 3).valid).toBe(false) + expect( + resolveSolarPanelPlacement(segment, panel, 0, 0, { + obstacles: [ + [ + [-1, -1], + [1, -1], + [1, 1], + [-1, 1], + ], + ], + }).valid, + ).toBe(false) +}) diff --git a/packages/nodes/src/solar-panel/auto-fit.ts b/packages/nodes/src/solar-panel/auto-fit.ts new file mode 100644 index 0000000000..68b87560bf --- /dev/null +++ b/packages/nodes/src/solar-panel/auto-fit.ts @@ -0,0 +1,136 @@ +import { + type AnyNode, + difference, + intersection, + nodeRegistry, + type Ring, + type RoofSegmentNode, + type SolarPanelNode, +} from '@pascal-app/core' +import { getRoofOuterSurfaceFrameAtPoint } from '@pascal-app/viewer' +import * as THREE from 'three' +import { roofFootprintInsideTrim } from '../shared/roof-footprint-trim' +import { getRoofSurfaceFaceBoundsAt, surfaceQuatFromNormal } from '../shared/roof-surface' +import { roofSurfaceFootprintFromNode } from '../shared/roof-surface-placement-guides' +import { solarPanelMountLift } from './geometry' + +function hull(points: Ring): Ring { + const sorted = points.sort((a, b) => a[0] - b[0] || a[1] - b[1]) + const cross = (a: Ring[number], b: Ring[number], c: Ring[number]) => + (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]) + const half = (vertices: Ring) => { + const result: Ring = [] + for (const point of vertices) { + while (result.length >= 2 && cross(result.at(-2)!, result.at(-1)!, point) <= 0) result.pop() + result.push(point) + } + return result.slice(0, -1) + } + return [...half(sorted), ...half([...sorted].reverse())] +} + +function projectedBox( + width: number, + depth: number, + bottom: number, + top: number, + position: readonly number[], + rotation: THREE.Quaternion, +): Ring { + const points: Ring = [] + for (const x of [-width / 2, width / 2]) { + for (const z of [-depth / 2, depth / 2]) { + for (const y of [bottom, top]) { + const p = new THREE.Vector3(x, y, z).applyQuaternion(rotation) + points.push([p.x + position[0]!, p.z + position[2]!]) + } + } + } + return hull(points) +} + +function panelRotation(segment: RoofSegmentNode, panel: SolarPanelNode): THREE.Quaternion { + const frame = getRoofOuterSurfaceFrameAtPoint(segment, panel.position[0], panel.position[2]) + return surfaceQuatFromNormal(frame.normal, new THREE.Quaternion()) + .multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), panel.rotation)) + .multiply( + new THREE.Quaternion().setFromAxisAngle( + new THREE.Vector3(1, 0, 0), + panel.mountingType === 'tilted' ? (panel.tiltAngle * Math.PI) / 180 : 0, + ), + ) +} + +/** The projected outer envelope, including the frame and its mounting height. */ +export function solarPanelFootprint(segment: RoofSegmentNode, panel: SolarPanelNode): Ring { + return projectedBox( + panel.columns * panel.panelWidth + (panel.columns - 1) * panel.gapX, + panel.rows * panel.panelHeight + (panel.rows - 1) * panel.gapY, + panel.standoffHeight + solarPanelMountLift(panel), + panel.standoffHeight + solarPanelMountLift(panel) + panel.frameDepth, + panel.position, + panelRotation(segment, panel), + ) +} + +/** Conservative occupied envelopes, including skylights and dormer roof openings. */ +export function solarPanelObstacles( + segment: RoofSegmentNode, + panel: SolarPanelNode, + nodes: readonly AnyNode[], +): Ring[] { + const obstacles: Ring[] = [] + for (const node of nodes) { + if (node.id === panel.id || !nodeRegistry.get(node.type)?.capabilities.roofAccessory) continue + if (!('roofSegmentId' in node) || node.roofSegmentId !== segment.id) continue + if (node.type === 'solar-panel') { + obstacles.push(solarPanelFootprint(segment, node)) + continue + } + if (!('position' in node) || !Array.isArray(node.position)) continue + const footprint = roofSurfaceFootprintFromNode(node, { segment }) + const upright = + node.type === 'chimney' || + node.type === 'dormer' || + node.type === 'ridge-vent' || + node.type === 'gutter' + const rotation = upright + ? new THREE.Quaternion() + : surfaceQuatFromNormal( + getRoofOuterSurfaceFrameAtPoint(segment, node.position[0], node.position[2]).normal, + new THREE.Quaternion(), + ) + rotation.multiply( + new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), footprint.rotation ?? 0), + ) + const height = + 'height' in node && typeof node.height === 'number' ? Math.max(0, node.height) : 0 + obstacles.push( + projectedBox(footprint.width, footprint.depth, 0, height, node.position, rotation), + ) + } + return obstacles +} + +/** Maximize panel count at the current center and orientation, without crossing a face or obstacle. */ +export function computeAutoFit( + segment: RoofSegmentNode, + panel: SolarPanelNode, + obstacles: readonly Ring[] = [], +): { rows: number; columns: number } | null { + if (!(panel.panelWidth > 0 && panel.panelHeight > 0 && panel.gapX >= 0 && panel.gapY >= 0)) + return null + const face = getRoofSurfaceFaceBoundsAt(segment, panel.position[0], panel.position[2]).polygon + let best: { rows: number; columns: number } | null = null + for (let rows = 1; rows <= 20; rows++) { + for (let columns = 1; columns <= 20; columns++) { + if (best && rows * columns <= best.rows * best.columns) continue + const footprint = solarPanelFootprint(segment, { ...panel, rows, columns }) + if (footprint.length < 3 || !roofFootprintInsideTrim(segment, footprint)) continue + if (difference(footprint, face, { throwOnError: true }).length > 0) continue + if (obstacles.some((obstacle) => intersection(footprint, obstacle).length > 0)) continue + best = { rows, columns } + } + } + return best +} diff --git a/packages/nodes/src/solar-panel/definition.ts b/packages/nodes/src/solar-panel/definition.ts index 9f8ebcd116..2f647c3a29 100644 --- a/packages/nodes/src/solar-panel/definition.ts +++ b/packages/nodes/src/solar-panel/definition.ts @@ -228,6 +228,7 @@ const solarPanelHandles: HandleDescriptor[] = [ export const solarPanelDefinition: NodeDefinition = { kind: 'solar-panel', schemaVersion: 1, + snapProfile: 'item', schema: SolarPanelNode, category: 'structure', surfaceRole: 'roof', @@ -236,6 +237,9 @@ export const solarPanelDefinition: NodeDefinition = { const stub = SolarPanelNodeSchema.parse({ id: 'solarpanel_default' as never, type: 'solar-panel', + rows: 1, + columns: 1, + panelTypePreset: 'residential', }) const { id: _id, type: _type, ...rest } = stub return rest @@ -269,6 +273,7 @@ export const solarPanelDefinition: NodeDefinition = { }, toolHints: [ { key: 'Left click', label: 'Place solar panel array on roof' }, + { key: 'Alt', label: 'Place freely' }, { key: 'Esc', label: 'Cancel' }, ], diff --git a/packages/nodes/src/solar-panel/geometry.ts b/packages/nodes/src/solar-panel/geometry.ts index b0fd944a4f..d1efe315c4 100644 --- a/packages/nodes/src/solar-panel/geometry.ts +++ b/packages/nodes/src/solar-panel/geometry.ts @@ -1,4 +1,4 @@ -import type { RoofSegmentNode, SolarPanelNode } from '@pascal-app/core' +import type { SolarPanelNode } from '@pascal-app/core' import * as THREE from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { MeshStandardNodeMaterial } from 'three/webgpu' @@ -104,6 +104,12 @@ export function getDefaultPanelMaterial(): THREE.Material { * places this geometry in segment-local space with the surface tilt * applied as an outer JSX rotation. */ +export function solarPanelMountLift(node: SolarPanelNode): number { + const angle = node.mountingType === 'tilted' ? (node.tiltAngle * Math.PI) / 180 : 0 + const depth = node.rows * node.panelHeight + (node.rows - 1) * node.gapY + return (depth / 2) * Math.abs(Math.tan(angle)) +} + export function buildSolarPanelGeometry(node: SolarPanelNode): THREE.BufferGeometry | null { const { rows, @@ -122,6 +128,8 @@ export function buildSolarPanelGeometry(node: SolarPanelNode): THREE.BufferGeome const totalW = columns * panelWidth + (columns - 1) * gapX const totalH = rows * panelHeight + (rows - 1) * gapY + const lift = solarPanelMountLift(node) + const angle = node.mountingType === 'tilted' ? (node.tiltAngle * Math.PI) / 180 : 0 const originX = -totalW / 2 const originZ = -totalH / 2 @@ -129,13 +137,14 @@ export function buildSolarPanelGeometry(node: SolarPanelNode): THREE.BufferGeome for (let c = 0; c < columns; c++) { const cx = originX + c * (panelWidth + gapX) + panelWidth / 2 const cz = originZ + r * (panelHeight + gapY) + panelHeight / 2 - const y = standoffHeight + frameDepth / 2 + const y = standoffHeight + lift + frameDepth / 2 - const glassW = panelWidth - 2 * frameThickness - const glassH = panelHeight - 2 * frameThickness + const ft = Math.min(Math.max(0, frameThickness), Math.min(panelWidth, panelHeight) * 0.45) + const glassW = panelWidth - 2 * ft + const glassH = panelHeight - 2 * ft if (glassW > 0 && glassH > 0) { const glass = new THREE.BoxGeometry(glassW, frameDepth * 0.6, glassH) - glass.translate(cx, y + frameDepth * 0.2, cz) + glass.translate(cx, y + frameDepth * 0.1, cz) // Stretch the cell UVs so a tiled cell texture reads correctly // regardless of the panel's aspect ratio. const cellsU = Math.max(1, Math.round(glassW / SOLAR_CELL_SIZE_M)) @@ -148,9 +157,38 @@ export function buildSolarPanelGeometry(node: SolarPanelNode): THREE.BufferGeome panelGeos.push(glass) } - const ft = frameThickness const fd = frameDepth + const backsheet = new THREE.BoxGeometry(glassW, Math.min(0.008, fd * 0.2), glassH) + backsheet.translate(cx, y - fd * 0.4, cz) + frameGeos.push(backsheet) + for (const offset of [-panelHeight * 0.3, panelHeight * 0.3]) { + const rail = new THREE.BoxGeometry(panelWidth, 0.018, 0.035) + const railY = standoffHeight + lift - 0.009 + rail.translate(cx, railY, cz + offset) + frameGeos.push(rail) + for (const side of [-1, 1]) { + const top = new THREE.Vector3(cx + side * panelWidth * 0.35, railY, cz + offset) + const rotated = top.clone().applyAxisAngle(new THREE.Vector3(1, 0, 0), angle) + const bottom = new THREE.Vector3(rotated.x, 0, rotated.z).applyAxisAngle( + new THREE.Vector3(1, 0, 0), + -angle, + ) + const length = top.distanceTo(bottom) + if (length > 0.001) { + const leg = new THREE.BoxGeometry(0.025, length, 0.025) + leg.applyQuaternion( + new THREE.Quaternion().setFromUnitVectors( + new THREE.Vector3(0, 1, 0), + top.clone().sub(bottom).normalize(), + ), + ) + leg.translate(...top.clone().add(bottom).multiplyScalar(0.5).toArray()) + frameGeos.push(leg) + } + } + } + const left = new THREE.BoxGeometry(ft, fd, panelHeight) left.translate(cx - panelWidth / 2 + ft / 2, y, cz) frameGeos.push(left) @@ -192,44 +230,6 @@ export function buildSolarPanelGeometry(node: SolarPanelNode): THREE.BufferGeome // ─── Layout helpers (used by the inspector / placement tool) ───────── -function getSlopeDepthBounds( - segment: RoofSegmentNode, - panelLocalZ: number, -): { minZ: number; maxZ: number } { - const halfD = segment.depth / 2 - switch (segment.roofType) { - case 'gable': - case 'gambrel': - case 'dutch': - case 'mansard': - case 'hip': - return panelLocalZ >= 0 ? { minZ: 0, maxZ: halfD } : { minZ: -halfD, maxZ: 0 } - default: - return { minZ: -halfD, maxZ: halfD } - } -} - -/** - * Return the rows/columns that fit the array edge-to-edge on the slope - * the panel is sitting on. Returns null when nothing fits. Capped at - * the schema's hard limit of 20. - */ -export function computeAutoFit( - segment: RoofSegmentNode, - panel: SolarPanelNode, -): { rows: number; columns: number } | null { - const { minZ, maxZ } = getSlopeDepthBounds(segment, panel.position[2] ?? 0) - const usableW = segment.width - const usableD = maxZ - minZ - if (usableW <= 0 || usableD <= 0) return null - - const columns = Math.floor((usableW + panel.gapX) / (panel.panelWidth + panel.gapX)) - const rows = Math.floor((usableD + panel.gapY) / (panel.panelHeight + panel.gapY)) - if (columns < 1 || rows < 1) return null - - return { rows: Math.min(rows, 20), columns: Math.min(columns, 20) } -} - export function flippedPanelDims(panel: SolarPanelNode): { panelWidth: number panelHeight: number diff --git a/packages/nodes/src/solar-panel/index.ts b/packages/nodes/src/solar-panel/index.ts index c0f9399f2e..875ca7c0c8 100644 --- a/packages/nodes/src/solar-panel/index.ts +++ b/packages/nodes/src/solar-panel/index.ts @@ -1,4 +1,7 @@ export { getAnalyticalNormal, getSurfaceY, surfaceQuatFromNormal } from '../shared/roof-surface' +export { computeAutoFit, solarPanelFootprint, solarPanelObstacles } from './auto-fit' export { solarPanelDefinition } from './definition' -export { buildSolarPanelGeometry, computeAutoFit, flippedPanelDims } from './geometry' +export { buildSolarPanelGeometry, flippedPanelDims, solarPanelMountLift } from './geometry' + +export { resolveSolarPanelPlacement } from './placement' export { SolarPanelNode } from './schema' diff --git a/packages/nodes/src/solar-panel/move-tool.tsx b/packages/nodes/src/solar-panel/move-tool.tsx index c295dd6789..ebc6078d7f 100644 --- a/packages/nodes/src/solar-panel/move-tool.tsx +++ b/packages/nodes/src/solar-panel/move-tool.tsx @@ -11,72 +11,51 @@ import { useScene, } from '@pascal-app/core' import { + commitFreshPlacementSubtree, consumePlacementDragRelease, - EDITOR_LAYER, + isGridSnapActive, + isMagneticSnapActive, markToolCancelConsumed, + stripPlacementMetadataFlags, triggerSFX, useEditor, } from '@pascal-app/editor' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { getRoofOuterSurfaceFrameAtPoint } from '@pascal-app/viewer' +import { useCallback, useEffect, useState } from 'react' import * as THREE from 'three' import { createRelativeRoofDrag, type RelativeRoofDragTarget, roofSegmentLocalToBuildingLocal, - snapRelativeRoofDragTarget, } from '../shared/relative-roof-drag' -import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' +import { surfaceQuatFromNormal } from '../shared/roof-surface' import { clearRoofSurfacePlacementGuides, publishRoofSurfaceNodePlacementGuides, snapRoofSurfaceNodeTarget, } from '../shared/roof-surface-placement-guides' - -// MeshBasicMaterial: avoids the WebGPU "Color target has no corresponding -// fragment stage output / writeMask not zero" error that fires when -// MeshStandardMaterial (which writes to the MRT normal/roughness targets) -// is rendered in a pass whose render target lacks those attachments. -// Same fix as skylight's glass material. Visually identical for a ghost. -const previewMaterial = new THREE.MeshBasicMaterial({ - color: 0x22_44_88, - transparent: true, - opacity: 0.5, - depthWrite: false, -}) - +import { solarPanelObstacles } from './auto-fit' +import { resolveSolarPanelPlacement } from './placement' +import SolarPanelPreview from './preview' + +/** + * Solar-panel 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 MoveSolarPanelTool({ node }: { node: SolarPanelNode }) { const exitMoveMode = useCallback(() => { useEditor.getState().setMovingNode(null) }, []) - const previewRef = useRef(null!) - const [previewPos, setPreviewPos] = useState<[number, number, number]>([0, 0, 0]) - // Yaw = roof.rotation + segment.rotation; applied as outer rotation-y - // so the surface quat (segment-local) composes correctly — same pattern - // as the placement tool ghost. + const [previewPos, setPreviewPos] = useState<[number, number, number] | null>(null) + const [previewSurfaceQuat, setPreviewSurfaceQuat] = useState(null) + const [previewValid, setPreviewValid] = useState(true) const [previewYaw, setPreviewYaw] = useState(0) - const [previewSurfaceQuat, setPreviewSurfaceQuat] = useState( - new THREE.Quaternion(), - ) - const [hasHit, setHasHit] = useState(false) - - // Compact 2×3 ghost — same size as the placement tool ghost. - const previewGeo = useMemo(() => { - const ghostRows = 2 - const ghostCols = 3 - const totalW = ghostCols * node.panelWidth + (ghostCols - 1) * node.gapX - const totalH = ghostRows * node.panelHeight + (ghostRows - 1) * node.gapY - const geo = new THREE.BoxGeometry(totalW, node.frameDepth, totalH) - geo.translate(0, node.standoffHeight + node.frameDepth / 2, 0) - return geo - }, [ - node.panelWidth, - node.panelHeight, - node.gapX, - node.gapY, - node.frameDepth, - node.standoffHeight, - ]) useEffect(() => { useScene.temporal.getState().pause() @@ -88,42 +67,69 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) { parentId: node.parentId, metadata: node.metadata, } - const meta = typeof node.metadata === 'object' && node.metadata !== null ? (node.metadata as Record) : {} const isNew = !!meta.isNew - useScene.getState().updateNode(node.id as AnyNodeId, { - metadata: { ...meta, isTransient: true }, - }) - const panelObj = sceneRegistry.nodes.get(node.id) - if (panelObj) panelObj.visible = false + const ventObj = sceneRegistry.nodes.get(node.id) + if (ventObj) ventObj.visible = false - let lastSnapX = 0 - let lastSnapZ = 0 + let lastSnap: [number, number] | null = null let lastTarget: RelativeRoofDragTarget | null = null let committed = false + let forcePlacement = false const roofDrag = createRelativeRoofDrag(original) const clearTarget = () => { lastTarget = null - setHasHit(false) + lastSnap = null + setPreviewPos(null) + setPreviewSurfaceQuat(null) clearRoofSurfacePlacementGuides() } const resolveSnappedTarget = (event: RoofEvent): RelativeRoofDragTarget | null => { const rawTarget = roofDrag.resolve(event) if (!rawTarget) return null - return snapRoofSurfaceNodeTarget({ - target: snapRelativeRoofDragTarget(rawTarget, event.nativeEvent?.shiftKey === true), + forcePlacement = event.nativeEvent?.altKey === true + let placement = resolveSolarPanelPlacement( + rawTarget.segment, node, - bypass: event.nativeEvent?.shiftKey === true, - }) + rawTarget.localX, + rawTarget.localZ, + { + obstacles: solarPanelObstacles( + rawTarget.segment, + node, + Object.values(useScene.getState().nodes), + ), + gridStep: !forcePlacement && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0, + }, + ) + let target = { + ...rawTarget, + localX: placement.position[0], + localY: placement.position[1], + localZ: placement.position[2], + } + if (!forcePlacement && isMagneticSnapActive()) { + target = snapRoofSurfaceNodeTarget({ target, node }) + placement = resolveSolarPanelPlacement(target.segment, node, target.localX, target.localZ, { + obstacles: solarPanelObstacles( + target.segment, + node, + Object.values(useScene.getState().nodes), + ), + }) + target = { ...target, localY: placement.position[1] } + } + setPreviewValid(placement.valid || forcePlacement) + return target } - const updateGhost = (event: RoofEvent) => { + const updatePreview = (event: RoofEvent) => { const target = resolveSnappedTarget(event) if (!target) { clearTarget() @@ -133,20 +139,21 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) { const sx = Math.round(target.localX * 20) / 20 const sz = Math.round(target.localZ * 20) / 20 - if (event.nativeEvent?.shiftKey !== true && (sx !== lastSnapX || sz !== lastSnapZ)) { + if ( + !forcePlacement && + (isGridSnapActive() || isMagneticSnapActive()) && + (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) + ) { triggerSFX('sfx:grid-snap') - lastSnapX = sx - lastSnapZ = sz + lastSnap = [sx, sz] } - // Use the same analytical approach as the placement tool so the - // ghost orientation matches the committed panel exactly regardless - // of segment rotation. The placement tool's ghost is always correct - // because analytical normals are computed in segment-local space - // and the yaw is applied explicitly, avoiding any world-vs-local - // normal mismatch. - const segLocalNormal = getAnalyticalNormal(target.localX, target.localZ, target.segment) - setPreviewSurfaceQuat(surfaceQuatFromNormal(segLocalNormal, new THREE.Quaternion())) + const normal = getRoofOuterSurfaceFrameAtPoint( + target.segment, + target.localX, + target.localZ, + ).normal + setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion())) setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0)) setPreviewPos( roofSegmentLocalToBuildingLocal(target.segment.id, [ @@ -155,7 +162,6 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) { target.localZ, ]), ) - setHasHit(true) publishRoofSurfaceNodePlacementGuides({ roof: event.node as RoofNode, segment: target.segment, @@ -167,60 +173,42 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) { const onRoofClick = (event: RoofEvent) => { if (committed) return - const st = useScene.getState() - - const target = lastTarget ?? resolveSnappedTarget(event) + const target = event.node ? resolveSnappedTarget(event) : lastTarget if (!target) return + if ( + !forcePlacement && + !resolveSolarPanelPlacement(target.segment, node, target.localX, target.localZ, { + obstacles: solarPanelObstacles( + target.segment, + node, + Object.values(useScene.getState().nodes), + ), + }).valid + ) + return committed = true - const targetSegmentId = target.segment.id as AnyNodeId + const st = useScene.getState() - // Compute segment-local normal for the committed node so the - // renderer's surfaceQuat + outer segment.rotation compose to - // the same world orientation the ghost showed. - const segLocalNormal = getAnalyticalNormal(target.localX, target.localZ, target.segment) - - st.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, - }) - useScene.temporal.getState().resume() - - st.updateNode(node.id as AnyNodeId, { + const patch = { roofSegmentId: targetSegmentId, parentId: targetSegmentId, - position: [target.localX, target.localY, target.localZ], + position: [target.localX, target.localY, target.localZ] as [number, number, number], rotation: original.rotation, - // Segment-local normal — must stay consistent with getAnalyticalNormal - // semantics so the renderer's surfaceQuat is in the correct frame. - surfaceNormal: [segLocalNormal.x, segLocalNormal.y, segLocalNormal.z], visible: true, - metadata: {}, - }) - - if (original.roofSegmentId && original.roofSegmentId !== (targetSegmentId as string)) { - const oldSeg = st.nodes[original.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined - if (oldSeg) { - st.updateNode(original.roofSegmentId as AnyNodeId, { - children: (oldSeg.children ?? []).filter((id) => id !== node.id), - }) - } - 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(original.roofSegmentId as AnyNodeId) + metadata: stripPlacementMetadataFlags(original.metadata) as SolarPanelNode['metadata'], } + if (isNew) { + commitFreshPlacementSubtree(node.id as AnyNodeId, patch) + } else { + useScene.temporal.getState().resume() + st.updateNode(node.id as AnyNodeId, patch) + useScene.temporal.getState().pause() + } + st.dirtyNodes.add(targetSegmentId) st.dirtyNodes.add(node.id as AnyNodeId) - useScene.temporal.getState().pause() - const obj = sceneRegistry.nodes.get(node.id) if (obj) obj.visible = true @@ -232,19 +220,19 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) { const onCancel = () => { if (isNew) { - useScene.temporal.getState().resume() - const parentId = original.roofSegmentId + // 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 AnyNodeId] as - | RoofSegmentNode - | undefined + const parent = useScene.getState().nodes[parentId] as RoofSegmentNode | undefined if (parent) { - useScene.getState().updateNode(parentId as AnyNodeId, { + useScene.getState().updateNode(parentId, { children: (parent.children ?? []).filter((id) => id !== node.id), }) } } useScene.getState().deleteNode(node.id as AnyNodeId) + useScene.temporal.getState().resume() markToolCancelConsumed() clearRoofSurfacePlacementGuides() exitMoveMode() @@ -261,7 +249,6 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) { if (original.roofSegmentId) { useScene.getState().dirtyNodes.add(original.roofSegmentId as AnyNodeId) } - const obj = sceneRegistry.nodes.get(node.id) if (obj) obj.visible = true @@ -280,21 +267,24 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) { } as unknown as RoofEvent) } - emitter.on('roof:move', updateGhost) - emitter.on('roof:enter', updateGhost) + emitter.on('roof:move', updatePreview) + emitter.on('roof:enter', updatePreview) emitter.on('roof:click', onRoofClick) emitter.on('roof:leave', clearTarget) emitter.on('tool:cancel', onCancel) window.addEventListener('pointerup', onPlacementDragPointerUp) return () => { - emitter.off('roof:move', updateGhost) - emitter.off('roof:enter', updateGhost) + emitter.off('roof:move', updatePreview) + emitter.off('roof:enter', updatePreview) emitter.off('roof:click', onRoofClick) emitter.off('roof:leave', clearTarget) emitter.off('tool:cancel', onCancel) window.removeEventListener('pointerup', onPlacementDragPointerUp) + // 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 clearRoofSurfacePlacementGuides() @@ -302,17 +292,13 @@ export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) { } }, [exitMoveMode, node]) - // Ghost layout mirrors the placement tool exactly: - // position (building-local hit point) - // → rotation-y (roof.rotation + segment.rotation — explicit yaw) - // → quaternion (segment-local surface tilt) - // This is identical to the placement ghost so drag and commit always - // show the same orientation. + if (!(previewPos && previewSurfaceQuat)) return null + return ( - + - + diff --git a/packages/nodes/src/solar-panel/panel.tsx b/packages/nodes/src/solar-panel/panel.tsx index cae977bb83..493d9c099e 100644 --- a/packages/nodes/src/solar-panel/panel.tsx +++ b/packages/nodes/src/solar-panel/panel.tsx @@ -21,9 +21,10 @@ import { triggerSFX, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { LayoutGrid, Trash2 } from 'lucide-react' +import { Trash2 } from 'lucide-react' import { useCallback, useEffect, useState } from 'react' -import { computeAutoFit, flippedPanelDims } from './geometry' +import { computeAutoFit, solarPanelObstacles } from './auto-fit' +import { flippedPanelDims } from './geometry' const cn = (...classes: Array): string => classes.filter(Boolean).join(' ') @@ -143,9 +144,12 @@ export default function SolarPanelPanel() { const handleAutoFit = useCallback(() => { if (!(selectedId && node && segment)) return - const fit = computeAutoFit(segment, node) + const obstacles = solarPanelObstacles(segment, node, Object.values(useScene.getState().nodes)) + const fit = computeAutoFit(segment, node, obstacles) if (!fit) { - setAutoFitMessage('Setbacks too large to fit a panel.') + setAutoFitMessage( + 'No panel fits here. Move or rotate the array, or reduce the panel dimensions.', + ) return } updateNode(selectedId as AnyNode['id'], { rows: fit.rows, columns: fit.columns }) @@ -156,15 +160,6 @@ export default function SolarPanelPanel() { 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) @@ -181,13 +176,13 @@ export default function SolarPanelPanel() { return ( - +
{PRESET_CARDS.map((card) => { const dims = SOLAR_PANEL_PRESETS[card.key] @@ -200,15 +195,20 @@ export default function SolarPanelPanel() { ? 'border-orange-400/60 bg-orange-400/10 text-foreground' : 'border-border/50 bg-black/4 dark:bg-[#2C2C2E] text-muted-foreground hover:bg-black/8 dark:hover:bg-[#3e3e3e] hover:text-foreground', )} + aria-pressed={isSelected} key={card.key} onClick={() => handlePresetChange(card.key)} type="button" > + - {card.label} - + {formatDims(dims.panelWidth, dims.panelHeight)} @@ -223,6 +223,13 @@ export default function SolarPanelPanel() { +

+ {node.rows * node.columns} panels ·{' '} + {formatDims( + node.columns * node.panelWidth + (node.columns - 1) * node.gapX, + node.rows * node.panelHeight + (node.rows - 1) * node.gapY, + )} +

previewProp({ gapX: v })} @@ -258,7 +265,7 @@ export default function SolarPanelPanel() { value={num(node.gapX, 0.02)} /> previewProp({ gapY: v })} @@ -280,7 +287,6 @@ export default function SolarPanelPanel() { previewProp({ panelWidth: v })} onCommit={(v) => commitProp({ panelWidth: v })} @@ -291,8 +297,7 @@ export default function SolarPanelPanel() { value={num(node.panelWidth, 1)} /> previewProp({ panelHeight: v })} onCommit={(v) => commitProp({ panelHeight: v })} @@ -306,7 +311,7 @@ export default function SolarPanelPanel() { previewProp({ frameThickness: v })} @@ -368,6 +373,20 @@ export default function SolarPanelPanel() { /> + + previewProp({ rotation: (v * Math.PI) / 180 })} + onCommit={(v) => commitProp({ rotation: (v * Math.PI) / 180 })} + /> + 0) { + x = Math.round(x / step) * step + z = Math.round(z / step) * step + } + const frame = getRoofOuterSurfaceFrameAtPoint(segment, x, z) + const position: [number, number, number] = [x, frame.point.y, z] + const footprint = solarPanelFootprint(segment, { ...node, position }) + const face = getRoofSurfaceFaceBoundsAt(segment, x, z).polygon + return { + position, + rotation: node.rotation, + surfaceNormal: [frame.normal.x, frame.normal.y, frame.normal.z] as [number, number, number], + valid: + roofFootprintInsideTrim(segment, footprint) && + difference(footprint, face, { throwOnError: true }).length === 0 && + !(options.obstacles ?? []).some((o) => intersection(footprint, o).length > 0), + } +} diff --git a/packages/nodes/src/solar-panel/preview.tsx b/packages/nodes/src/solar-panel/preview.tsx index f5c97ec933..18a67680b7 100644 --- a/packages/nodes/src/solar-panel/preview.tsx +++ b/packages/nodes/src/solar-panel/preview.tsx @@ -42,6 +42,8 @@ const SolarPanelPreview = ({ node, invalid }: { node: SolarPanelNode; invalid?: node.frameThickness, node.frameDepth, node.standoffHeight, + node.mountingType, + node.tiltAngle, ], ) @@ -50,13 +52,17 @@ const SolarPanelPreview = ({ node, invalid }: { node: SolarPanelNode; invalid?: if (!geometry) return null return ( - { - /* preview should not intercept the cursor */ - }} - /> + + + { + /* preview should not intercept the cursor */ + }} + /> + + ) } diff --git a/packages/nodes/src/solar-panel/renderer.tsx b/packages/nodes/src/solar-panel/renderer.tsx index f921129947..afbb227d20 100644 --- a/packages/nodes/src/solar-panel/renderer.tsx +++ b/packages/nodes/src/solar-panel/renderer.tsx @@ -112,6 +112,8 @@ const SolarPanelRenderer = ({ node: storeNode }: { node: SolarPanelNode }) => { node.frameThickness, node.frameDepth, node.standoffHeight, + node.mountingType, + node.tiltAngle, ], ) diff --git a/packages/nodes/src/solar-panel/tool.test.tsx b/packages/nodes/src/solar-panel/tool.test.tsx new file mode 100644 index 0000000000..35e5837db3 --- /dev/null +++ b/packages/nodes/src/solar-panel/tool.test.tsx @@ -0,0 +1,78 @@ +import { expect, spyOn, test } from 'bun:test' +import { + BuildingNode, + emitter, + LevelNode, + RoofNode, + RoofSegmentNode, + sceneRegistry, + useScene, +} from '@pascal-app/core' +import { usePlacementPreview } from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { act, create } from '@react-three/test-renderer' +import * as THREE from 'three' +import { buildSolarPanelGeometry, solarPanelDefinition } from './index' + +test('roof hover previews the same full array that clicking creates', async () => { + const defaults = solarPanelDefinition.defaults() + const defaultsSpy = spyOn(solarPanelDefinition, 'defaults').mockImplementation(() => ({ + ...defaults, + rows: 4, + columns: 5, + })) + const savedScene = useScene.getState() + const savedSelection = useViewer.getState().selection + const building = BuildingNode.parse({}) + const level = LevelNode.parse({ parentId: building.id }) + const roof = RoofNode.parse({ parentId: level.id }) + const segment = RoofSegmentNode.parse({ parentId: roof.id, width: 20, depth: 20 }) + roof.children = [segment.id] + level.children = [roof.id] + building.children = [level.id] + useScene.setState({ + nodes: { [building.id]: building, [level.id]: level, [roof.id]: roof, [segment.id]: segment }, + rootNodeIds: [building.id], + dirtyNodes: new Set(), + readOnly: false, + }) + useViewer.getState().setSelection({ buildingId: building.id, levelId: level.id }) + sceneRegistry.nodes.set(segment.id, new THREE.Group()) + const { default: Tool } = await solarPanelDefinition.tool!() + const renderer = await create() + try { + const event = { + node: roof, + position: [0, 3, 3] as [number, number, number], + stopPropagation: () => {}, + } + await act(async () => emitter.emit('roof:move', event)) + const preview = usePlacementPreview.getState().node + expect(preview?.type).toBe('solar-panel') + if (preview?.type !== 'solar-panel') throw new Error('Missing solar preview') + await act(async () => emitter.emit('roof:click', event)) + const placed = Object.values(useScene.getState().nodes).find( + (node) => node.type === 'solar-panel', + ) + expect(placed).toBeDefined() + if (placed?.type !== 'solar-panel') throw new Error('Missing solar array') + expect([preview.rows, preview.columns]).toEqual([placed.rows, placed.columns]) + expect(preview.rows * preview.columns).toBe(20) + expect(preview.id).not.toBe(placed.id) + const previewGeometry = buildSolarPanelGeometry(preview)! + const placedGeometry = buildSolarPanelGeometry(placed)! + previewGeometry.computeBoundingBox() + placedGeometry.computeBoundingBox() + expect(previewGeometry.boundingBox!.equals(placedGeometry.boundingBox!)).toBe(true) + previewGeometry.dispose() + placedGeometry.dispose() + expect(usePlacementPreview.getState().node).toBeNull() + } finally { + defaultsSpy.mockRestore() + await renderer.unmount() + sceneRegistry.nodes.delete(segment.id) + useScene.setState(savedScene) + useViewer.getState().setSelection(savedSelection) + usePlacementPreview.getState().clear() + } +}) diff --git a/packages/nodes/src/solar-panel/tool.tsx b/packages/nodes/src/solar-panel/tool.tsx index 35b7909c98..e75a1a2b43 100644 --- a/packages/nodes/src/solar-panel/tool.tsx +++ b/packages/nodes/src/solar-panel/tool.tsx @@ -1,181 +1,168 @@ 'use client' +import { type AnyNodeId, emitter, type RoofEvent, SolarPanelNode, useScene } from '@pascal-app/core' import { - type AnyNodeId, - emitter, - type RoofEvent, - type RoofNode, - SolarPanelNode, - sceneRegistry, - useScene, -} from '@pascal-app/core' -import { triggerSFX, usePlacementPreview } from '@pascal-app/editor' -import { useViewer } from '@pascal-app/viewer' -import { useEffect, useMemo, useRef, useState } from 'react' + isGridSnapActive, + isMagneticSnapActive, + triggerSFX, + useEditor, + usePlacementPreview, +} from '@pascal-app/editor' +import { getRoofOuterSurfaceFrameAtPoint, useViewer } from '@pascal-app/viewer' +import { useEffect, useMemo, useState } from 'react' import * as THREE from 'three' +import { roofSegmentLocalToBuildingLocal } from '../shared/relative-roof-drag' import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' -import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' +import { surfaceQuatFromNormal } from '../shared/roof-surface' import { clearRoofSurfacePlacementGuides, - publishRoofSurfacePlacementGuides, - roofSurfaceFootprintFromNode, + publishRoofSurfaceNodePlacementGuides, + snapRoofSurfaceNodeTarget, } from '../shared/roof-surface-placement-guides' +import { solarPanelObstacles } from './auto-fit' import { solarPanelDefinition } from './definition' +import { resolveSolarPanelPlacement } from './placement' import SolarPanelPreview from './preview' -const worldPoint = new THREE.Vector3() - -/** - * Solar panel placement tool. The preview shows the array at the - * cursor with the analytical roof-surface tilt applied (no raycast in - * the placement preview — uses `getAnalyticalNormal` derived from the - * segment's roof type + dimensions). On commit, snaps the position's - * Y to the segment's surface height and stores the analytical normal - * in the node so the renderer reproduces the same orientation. - */ -const SolarPanelTool = () => { +export default function SolarPanelTool() { const activeBuildingId = useViewer((s) => s.selection.buildingId) - const setSelection = useViewer((s) => s.setSelection) - - const [previewPos, setPreviewPos] = useState<[number, number, number] | null>(null) - const [previewYaw, setPreviewYaw] = useState(0) - const [previewSurfaceQuat, setPreviewSurfaceQuat] = useState(null) - const lastSnapRef = useRef<[number, number] | null>(null) - - // Compact 2×3 ghost (rows × columns) — small enough to read as a - // pointer, large enough to show the array's orientation/aspect. - // The committed panel still uses the full residential defaults (4×5). - const previewNode = useMemo( - () => - SolarPanelNode.parse({ - ...solarPanelDefinition.defaults(), - name: 'Solar Panel', - position: [0, 0, 0], - rotation: 0, - rows: 2, - columns: 3, - }), + const seed = useMemo( + () => SolarPanelNode.parse({ ...solarPanelDefinition.defaults(), name: 'Solar Panel' }), [], ) + const [preview, setPreview] = useState<{ + node: SolarPanelNode + position: [number, number, number] + normal: THREE.Quaternion + yaw: number + valid: boolean + } | null>(null) 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 clear = () => { + setPreview(null) + if (usePlacementPreview.getState().node?.id === seed.id) + usePlacementPreview.getState().clear() + clearRoofSurfacePlacementGuides() } - - 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 (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== sx || prev[1] !== sz)) { - triggerSFX('sfx:grid-snap') - lastSnapRef.current = [sx, sz] + const resolve = (event: RoofEvent) => { + const hit = resolveRoofSegmentHit(event.node, ...event.position) + if (!hit) return null + const force = event.nativeEvent?.altKey === true + let placement = resolveSolarPanelPlacement(hit.segment, seed, hit.localX, hit.localZ, { + obstacles: solarPanelObstacles(hit.segment, seed, Object.values(useScene.getState().nodes)), + gridStep: !force && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0, + }) + if (!force && isMagneticSnapActive()) { + const target = snapRoofSurfaceNodeTarget({ + target: { + ...hit, + hit, + localX: placement.position[0], + localY: placement.position[1], + localZ: placement.position[2], + }, + node: { ...seed, rotation: placement.rotation }, + }) + placement = resolveSolarPanelPlacement(hit.segment, seed, target.localX, target.localZ, { + obstacles: solarPanelObstacles( + hit.segment, + seed, + Object.values(useScene.getState().nodes), + ), + }) } - - 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)) - usePlacementPreview.getState().set( - SolarPanelNode.parse({ - ...previewNode, - parentId: hit.segment.id, - position: [hit.localX, hit.localY, hit.localZ], - roofSegmentId: hit.segment.id, - surfaceNormal: [normal.x, normal.y, normal.z], - }), + return { hit, placement, force } + } + const show = (event: RoofEvent) => { + const resolved = resolve(event) + if (!resolved) { + clear() + return + } + const { hit, placement, force } = resolved + const node = { + ...seed, + position: placement.position, + rotation: placement.rotation, + parentId: hit.segment.id, + roofSegmentId: hit.segment.id, + } + const normal = getRoofOuterSurfaceFrameAtPoint( hit.segment, - ) - publishRoofSurfacePlacementGuides({ - roof: event.node as RoofNode, + placement.position[0], + placement.position[2], + ).normal + setPreview({ + node, + position: roofSegmentLocalToBuildingLocal(hit.segment.id, placement.position), + normal: surfaceQuatFromNormal(normal, new THREE.Quaternion()), + yaw: event.node.rotation + hit.segment.rotation, + valid: placement.valid || force, + }) + usePlacementPreview.getState().set(node, hit.segment) + publishRoofSurfaceNodePlacementGuides({ + roof: event.node, segment: hit.segment, - center: [hit.localX, hit.localY, hit.localZ], - footprint: roofSurfaceFootprintFromNode(previewNode), + center: placement.position, + node, }) 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() - - // Use the raycast hit Y (segment-local) and analytical normal so the - // committed panel sits exactly where the ghost was rendered. The - // analytical `getSurfaceY` is the bare-rafter height — it ignores - // deck/shingle layers and sinks the panel into the roof, producing - // a visible jump between ghost and committed mesh. - const normal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment) - - const panel = SolarPanelNode.parse({ - ...solarPanelDefinition.defaults(), - name: 'Solar Panel', + const place = (event: RoofEvent) => { + const resolved = resolve(event) + if (!resolved) return + const { hit, placement, force } = resolved + if (!placement.valid && !force) { + show(event) + return + } + const node = SolarPanelNode.parse({ + ...seed, + id: undefined, roofSegmentId: hit.segment.id, - position: [hit.localX, hit.localY, hit.localZ], - rotation: 0, - surfaceNormal: [normal.x, normal.y, normal.z], + position: placement.position, + rotation: placement.rotation, }) - state.createNode(panel, hit.segment.id as AnyNodeId) - state.dirtyNodes.add(hit.segment.id as AnyNodeId) - setSelection({ selectedIds: [panel.id] }) + useScene.getState().createNode(node, hit.segment.id as AnyNodeId) + useViewer.getState().setSelection({ selectedIds: [node.id] }) triggerSFX('sfx:item-place') - const placementPreview = usePlacementPreview.getState() - if (placementPreview.node?.id === previewNode.id) placementPreview.clear() - clearRoofSurfacePlacementGuides() + clear() event.stopPropagation() } - - emitter.on('roof:move', updatePreview) - emitter.on('roof:enter', updatePreview) - emitter.on('roof:click', onClick) - + emitter.on('roof:move', show) + emitter.on('roof:enter', show) + emitter.on('roof:leave', clear) + emitter.on('roof:click', place) + emitter.on('tool:cancel', clear) return () => { - emitter.off('roof:move', updatePreview) - emitter.off('roof:enter', updatePreview) - emitter.off('roof:click', onClick) - const placementPreview = usePlacementPreview.getState() - if (placementPreview.node?.id === previewNode.id) placementPreview.clear() - clearRoofSurfacePlacementGuides() + emitter.off('roof:move', show) + emitter.off('roof:enter', show) + emitter.off('roof:leave', clear) + emitter.off('roof:click', place) + emitter.off('tool:cancel', clear) + clear() } - }, [activeBuildingId, setSelection, previewNode]) + }, [activeBuildingId, seed]) return ( <> } + ghost={} onInvalidTarget={() => { - setPreviewPos(null) - setPreviewSurfaceQuat(null) - const placementPreview = usePlacementPreview.getState() - if (placementPreview.node?.id === previewNode.id) placementPreview.clear() + setPreview(null) + usePlacementPreview.getState().clear() clearRoofSurfacePlacementGuides() }} /> - {activeBuildingId && previewPos && previewSurfaceQuat && ( - - - - + {preview && ( + + + + @@ -183,5 +170,3 @@ const SolarPanelTool = () => { ) } - -export default SolarPanelTool diff --git a/packages/nodes/src/turbine-vent/__tests__/geometry.test.ts b/packages/nodes/src/turbine-vent/__tests__/geometry.test.ts index d99a3bc7e3..0bc5cfb903 100644 --- a/packages/nodes/src/turbine-vent/__tests__/geometry.test.ts +++ b/packages/nodes/src/turbine-vent/__tests__/geometry.test.ts @@ -64,3 +64,16 @@ describe('turbine vent geometry', () => { expect(allFinite(geo)).toBe(true) }) }) + +for (const style of ['globe', 'cylinder'] as const) { + test(`${style} stays within the configured total height, including its spindle`, () => { + for (const diameter of [0.1, 0.32, 2]) { + const node = TurbineVentNode.parse({ style, diameter, height: 0.12, neckHeight: 2 }) + const geometry = buildTurbineVentGeometry(node) + geometry.computeBoundingBox() + expect(geometry.boundingBox!.min.y).toBeCloseTo(0, 6) + expect(geometry.boundingBox!.max.y).toBeCloseTo(node.height, 6) + geometry.dispose() + } + }) +} diff --git a/packages/nodes/src/turbine-vent/definition.ts b/packages/nodes/src/turbine-vent/definition.ts index 48e995a9b4..1bcb973666 100644 --- a/packages/nodes/src/turbine-vent/definition.ts +++ b/packages/nodes/src/turbine-vent/definition.ts @@ -118,11 +118,13 @@ export const turbineVentDefinition: NodeDefinition = { preview: () => import('./preview'), tool: () => import('./tool'), + snapProfile: 'item', affordanceTools: { move: () => import('./move-tool'), }, toolHints: [ { key: 'Left click', label: 'Place turbine vent on roof' }, + { key: 'Alt', label: 'Place freely' }, { key: 'Esc', label: 'Cancel' }, ], diff --git a/packages/nodes/src/turbine-vent/floorplan.ts b/packages/nodes/src/turbine-vent/floorplan.ts index 96eb6359f5..58a5670223 100644 --- a/packages/nodes/src/turbine-vent/floorplan.ts +++ b/packages/nodes/src/turbine-vent/floorplan.ts @@ -66,7 +66,7 @@ export function buildTurbineVentFloorplan( const fillOpacity = showSelectedChrome ? 0.55 : 0.7 const lineWidth = showSelectedChrome ? 0.03 : 0.02 - const ro = Math.max(node.diameter, 0.05) / 2 + const ro = Math.max(node.diameter, 0.1) / 2 const flangeR = ro + Math.max(0, node.baseOverhang ?? 0.05) const circle = (r: number): FloorplanPoint[] => { diff --git a/packages/nodes/src/turbine-vent/geometry.ts b/packages/nodes/src/turbine-vent/geometry.ts index 01edb0ec38..6be625617c 100644 --- a/packages/nodes/src/turbine-vent/geometry.ts +++ b/packages/nodes/src/turbine-vent/geometry.ts @@ -93,21 +93,42 @@ export function buildTurbineVentHead(node: TurbineVentNode): THREE.BufferGeometr // Vanes. const dTheta = (Math.PI * 2) / d.vanes - const dw = dTheta * 0.55 + const dw = dTheta * 0.85 const totalTwist = dTheta * 0.9 buildVanes(p, n, uv, d.vanes, rings, dw, totalTwist, d.thk) + for (const ring of [rings[0]!, rings[rings.length - 1]!]) { + const band = new THREE.TorusGeometry( + ring.r - Math.min(d.thk * 0.6, d.headH * 0.02), + Math.min(d.thk * 0.6, d.headH * 0.02), + 6, + 48, + ) + band.rotateX(Math.PI / 2) + band.translate(0, ring.y, 0) + const positions = band.getAttribute('position') + const normals = band.getAttribute('normal') + const index = band.getIndex()! + for (let i = 0; i < index.count; i++) { + const j = index.getX(i) + p.push(positions.getX(j), positions.getY(j), positions.getZ(j)) + n.push(normals.getX(j), normals.getY(j), normals.getZ(j)) + uv.push(positions.getX(j), positions.getZ(j)) + } + band.dispose() + } + // Top cap + spindle knob. const topRing = rings[rings.length - 1]! if (node.style === 'cylinder') { // Straight barrel → flat disc lid with a slim rim. const lidY = topRing.y - cylinderWall(p, n, uv, topRing.r, lidY, lidY + d.headH * 0.04, 28) - disc(p, n, uv, topRing.r, lidY + d.headH * 0.04, [0, 1, 0]) - knob(p, n, uv, d.ro, lidY + d.headH * 0.04, d.headH) + cylinderWall(p, n, uv, topRing.r, lidY, d.headH * 0.9, 28) + disc(p, n, uv, topRing.r, d.headH * 0.9, [0, 1, 0]) + knob(p, n, uv, d.ro, d.headH * 0.9, d.headH) } else { // Globe → domed lid for the spherical whirlybird silhouette. - const apexY = d.headH * 0.96 + const apexY = d.headH * 0.9 dome(p, n, uv, topRing.r, topRing.y, apexY - topRing.y, 5, 24) knob(p, n, uv, d.ro, apexY, d.headH) } @@ -132,17 +153,13 @@ export function buildTurbineVentGeometry(node: TurbineVentNode): THREE.BufferGeo // constant radius for a straight barrel. function vaneRings(node: TurbineVentNode, d: Dims): Array<{ r: number; y: number }> { - if (node.style === 'cylinder') { - return [ - { r: d.ro, y: d.headH * 0.04 }, - { r: d.ro, y: d.headH * 0.85 }, - ] - } - return [ - { r: d.ro * 0.6, y: d.headH * 0.05 }, - { r: d.ro, y: d.headH * 0.42 }, - { r: d.ro * 0.58, y: d.headH * 0.72 }, - ] + return Array.from({ length: 13 }, (_, i) => { + const t = i / 12 + return { + r: d.ro * (node.style === 'cylinder' ? 1 : 0.6 + 0.4 * Math.sin(Math.PI * t)), + y: d.headH * (0.05 + 0.72 * t), + } + }) } // Each vane is a thin, twisted shell lofted through `rings`: an outer and @@ -331,9 +348,9 @@ function dome( // Small spindle knob at the top centre. function knob(p: number[], n: number[], uv: number[], ro: number, y0: number, headH: number): void { const kr = ro * 0.12 - const kh = headH * 0.06 + const kh = headH * 0.05 cylinderWall(p, n, uv, kr, y0, y0 + kh, 12) - dome(p, n, uv, kr, y0 + kh, kr * 0.9, 3, 12) + dome(p, n, uv, kr, y0 + kh, headH * 0.05, 3, 12) } // ─── Geometry plumbing ─────────────────────────────────────────────── diff --git a/packages/nodes/src/turbine-vent/index.ts b/packages/nodes/src/turbine-vent/index.ts index edf36566d6..2631f08be9 100644 --- a/packages/nodes/src/turbine-vent/index.ts +++ b/packages/nodes/src/turbine-vent/index.ts @@ -1,3 +1,4 @@ export { turbineVentDefinition } from './definition' export { buildTurbineVentGeometry } from './geometry' +export { resolveTurbineVentPlacement } from './placement' export { TurbineVentNode } from './schema' diff --git a/packages/nodes/src/turbine-vent/move-tool.tsx b/packages/nodes/src/turbine-vent/move-tool.tsx index 4ae4cf9556..c27ef251c5 100644 --- a/packages/nodes/src/turbine-vent/move-tool.tsx +++ b/packages/nodes/src/turbine-vent/move-tool.tsx @@ -11,34 +11,40 @@ import { useScene, } from '@pascal-app/core' import { + commitFreshPlacementSubtree, consumePlacementDragRelease, + isGridSnapActive, + isMagneticSnapActive, markToolCancelConsumed, + stripPlacementMetadataFlags, triggerSFX, useEditor, } from '@pascal-app/editor' +import { getRoofOuterSurfaceFrameAtPoint } from '@pascal-app/viewer' import { useCallback, useEffect, useState } from 'react' import * as THREE from 'three' import { createRelativeRoofDrag, type RelativeRoofDragTarget, roofSegmentLocalToBuildingLocal, - snapRelativeRoofDragTarget, } from '../shared/relative-roof-drag' -import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' +import { surfaceQuatFromNormal } from '../shared/roof-surface' import { clearRoofSurfacePlacementGuides, publishRoofSurfaceNodePlacementGuides, snapRoofSurfaceNodeTarget, } from '../shared/roof-surface-placement-guides' +import { resolveTurbineVentPlacement } from './placement' import TurbineVentPreview from './preview' /** - * Turbine-vent move tool. Mirrors the box-vent move flow: the original - * mesh hides during the drag, a 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 (reparenting between segments when - * needed). Cancel restores the original transform, or deletes a - * freshly-cloned vent (`metadata.isNew`). + * Turbine-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 MoveTurbineVentTool({ node }: { node: TurbineVentNode }) { const exitMoveMode = useCallback(() => { @@ -47,6 +53,7 @@ export default function MoveTurbineVentTool({ node }: { node: TurbineVentNode }) const [previewPos, setPreviewPos] = useState<[number, number, number] | null>(null) const [previewSurfaceQuat, setPreviewSurfaceQuat] = useState(null) + const [previewValid, setPreviewValid] = useState(true) const [previewYaw, setPreviewYaw] = useState(0) useEffect(() => { @@ -71,6 +78,15 @@ export default function MoveTurbineVentTool({ node }: { node: TurbineVentNode }) let lastSnap: [number, number] | null = null let lastTarget: RelativeRoofDragTarget | null = null let committed = false + let forcePlacement = false + const originalSegment = original.roofSegmentId + ? (useScene.getState().nodes[original.roofSegmentId as AnyNodeId] as RoofSegmentNode) + : undefined + const roofOffset = originalSegment + ? original.position[1] - + getRoofOuterSurfaceFrameAtPoint(originalSegment, original.position[0], original.position[2]) + .point.y + : 0 const roofDrag = createRelativeRoofDrag(original) const clearTarget = () => { @@ -84,11 +100,38 @@ export default function MoveTurbineVentTool({ node }: { node: TurbineVentNode }) const resolveSnappedTarget = (event: RoofEvent): RelativeRoofDragTarget | null => { const rawTarget = roofDrag.resolve(event) if (!rawTarget) return null - return snapRoofSurfaceNodeTarget({ - target: snapRelativeRoofDragTarget(rawTarget, event.nativeEvent?.shiftKey === true), + forcePlacement = event.nativeEvent?.altKey === true + let placement = resolveTurbineVentPlacement( + rawTarget.segment, node, - bypass: event.nativeEvent?.shiftKey === true, - }) + rawTarget.localX, + rawTarget.localZ, + { + roofOffset, + gridStep: !forcePlacement && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0, + }, + ) + let target = { + ...rawTarget, + localX: placement.position[0], + localY: placement.position[1], + localZ: placement.position[2], + } + if (!forcePlacement && isMagneticSnapActive()) { + target = snapRoofSurfaceNodeTarget({ target, node }) + placement = resolveTurbineVentPlacement( + target.segment, + node, + target.localX, + target.localZ, + { + roofOffset, + }, + ) + target = { ...target, localY: placement.position[1] } + } + setPreviewValid(placement.valid || forcePlacement) + return target } const updatePreview = (event: RoofEvent) => { @@ -102,14 +145,19 @@ export default function MoveTurbineVentTool({ node }: { node: TurbineVentNode }) const sx = Math.round(target.localX * 20) / 20 const sz = Math.round(target.localZ * 20) / 20 if ( - event.nativeEvent?.shiftKey !== true && + !forcePlacement && + (isGridSnapActive() || isMagneticSnapActive()) && (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) ) { triggerSFX('sfx:grid-snap') lastSnap = [sx, sz] } - const normal = getAnalyticalNormal(target.localX, target.localZ, target.segment) + const normal = getRoofOuterSurfaceFrameAtPoint( + target.segment, + target.localX, + target.localZ, + ).normal setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion())) setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0)) setPreviewPos( @@ -130,39 +178,34 @@ export default function MoveTurbineVentTool({ node }: { node: TurbineVentNode }) const onRoofClick = (event: RoofEvent) => { if (committed) return - const target = lastTarget ?? resolveSnappedTarget(event) + const target = event.node ? resolveSnappedTarget(event) : lastTarget if (!target) return + if ( + !forcePlacement && + !resolveTurbineVentPlacement(target.segment, node, target.localX, target.localZ, { + roofOffset, + }).valid + ) + return committed = true const targetSegmentId = target.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, { + const patch = { roofSegmentId: targetSegmentId, parentId: targetSegmentId, - position: [target.localX, target.localY, target.localZ], + position: [target.localX, target.localY, target.localZ] as [number, number, number], rotation: original.rotation, visible: true, - metadata: {}, - }) - useScene.temporal.getState().pause() + metadata: stripPlacementMetadataFlags(original.metadata) as TurbineVentNode['metadata'], + } + if (isNew) { + commitFreshPlacementSubtree(node.id as AnyNodeId, patch) + } else { + useScene.temporal.getState().resume() + st.updateNode(node.id as AnyNodeId, patch) + useScene.temporal.getState().pause() + } st.dirtyNodes.add(targetSegmentId) st.dirtyNodes.add(node.id as AnyNodeId) @@ -178,6 +221,8 @@ export default function MoveTurbineVentTool({ node }: { node: TurbineVentNode }) 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 @@ -238,6 +283,9 @@ export default function MoveTurbineVentTool({ node }: { node: TurbineVentNode }) emitter.off('tool:cancel', onCancel) window.removeEventListener('pointerup', onPlacementDragPointerUp) + // 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 clearRoofSurfacePlacementGuides() @@ -251,7 +299,7 @@ export default function MoveTurbineVentTool({ node }: { node: TurbineVentNode }) - + diff --git a/packages/nodes/src/turbine-vent/panel.tsx b/packages/nodes/src/turbine-vent/panel.tsx index 09d0b9edd7..76689a1210 100644 --- a/packages/nodes/src/turbine-vent/panel.tsx +++ b/packages/nodes/src/turbine-vent/panel.tsx @@ -3,7 +3,6 @@ import { type AnyNode, type AnyNodeId, - getActiveRoofHeight, type RoofSegmentNode, useLiveNodeOverrides, useScene, @@ -14,12 +13,11 @@ import { duplicateNodeAndPickUp, PanelSection, PanelWrapper, - SegmentedControl, SliderControl, triggerSFX, useEditor, } from '@pascal-app/editor' -import { useViewer } from '@pascal-app/viewer' +import { getRoofOuterSurfaceFrameAtPoint, useViewer } from '@pascal-app/viewer' import { Copy, Move, Pause, Play, Trash2 } from 'lucide-react' import { useCallback, useRef } from 'react' import type { TurbineVentNode } from './schema' @@ -102,15 +100,6 @@ export default function TurbineVentPanel() { 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) @@ -122,47 +111,61 @@ export default function TurbineVentPanel() { // Play / pause the idle spin. Pausing remembers the current speed so // Play restores it (falling back to the default if it was never running). - const lastSpeedRef = useRef(DEFAULT_SPIN_SPEED) + const lastSpeedRef = useRef(new Map()) const isSpinning = (node?.spinSpeed ?? 0) > 0 const handleToggleSpin = useCallback(() => { if (!node) return const current = node.spinSpeed ?? 0 if (current > 0) { - lastSpeedRef.current = current + lastSpeedRef.current.set(node.id, current) handleUpdate({ spinSpeed: 0 }) } else { handleUpdate({ - spinSpeed: lastSpeedRef.current > 0 ? lastSpeedRef.current : DEFAULT_SPIN_SPEED, + spinSpeed: lastSpeedRef.current.get(node.id) ?? DEFAULT_SPIN_SPEED, }) } }, [node, handleUpdate]) if (!(node && node.type === 'turbine-vent' && selectedId)) return null + const surfaceY = (x: number, z: number) => + segment ? getRoofOuterSurfaceFrameAtPoint(segment, x, z).point.y : 0 + const roofOffset = node.position[1] - surfaceY(node.position[0], node.position[2]) + const at = (x: number, z: number): [number, number, number] => [x, surfaceY(x, z) + roofOffset, z] + return ( - handleUpdate({ style: v as TurbineVentNode['style'] })} - options={[ - { label: 'Globe', value: 'globe' }, - { label: 'Cylinder', value: 'cylinder' }, - ]} - value={node.style ?? 'globe'} - /> +
+ {(['globe', 'cylinder'] as const).map((style) => ( + + ))} +
previewProp({ diameter: v })} onCommit={(v) => handleUpdate({ diameter: v })} precision={2} @@ -173,10 +176,13 @@ export default function TurbineVentPanel() { /> previewProp({ height: v })} - onCommit={(v) => handleUpdate({ height: v })} + min={0.12} + onChange={(v) => + previewProp({ height: v, neckHeight: Math.min(node.neckHeight, v * 0.5) }) + } + onCommit={(v) => + handleUpdate({ height: v, neckHeight: Math.min(node.neckHeight, v * 0.5) }) + } precision={2} restoreOnCommit={false} step={0.01} @@ -185,7 +191,7 @@ export default function TurbineVentPanel() { /> previewProp({ neckHeight: v })} onCommit={(v) => handleUpdate({ neckHeight: v })} @@ -209,6 +215,38 @@ export default function TurbineVentPanel() { /> + + previewProp({ baseOverhang: v })} + onCommit={(v) => handleUpdate({ baseOverhang: v })} + precision={2} + step={0.01} + unit="m" + restoreOnCommit={false} + /> + + + previewProp({ spinSpeed: v })} onCommit={(v) => handleUpdate({ spinSpeed: v })} @@ -231,17 +268,13 @@ export default function TurbineVentPanel() { /> - + - previewProp({ position: [v, node.position[1] ?? 0, node.position[2] ?? 0] }) - } - onCommit={(v) => - handleUpdate({ position: [v, node.position[1] ?? 0, node.position[2] ?? 0] }) - } + onChange={(v) => previewProp({ position: at(v, node.position[2]) })} + onCommit={(v) => handleUpdate({ position: at(v, node.position[2]) })} precision={2} restoreOnCommit={false} step={0.05} @@ -249,34 +282,37 @@ export default function TurbineVentPanel() { value={node.position[0] ?? 0} /> - previewProp({ position: [node.position[0] ?? 0, v, node.position[2] ?? 0] }) + previewProp({ + position: [ + node.position[0], + surfaceY(node.position[0], node.position[2]) + v, + node.position[2], + ], + }) } onCommit={(v) => - handleUpdate({ position: [node.position[0] ?? 0, v, node.position[2] ?? 0] }) + handleUpdate({ + position: [ + node.position[0], + surfaceY(node.position[0], node.position[2]) + v, + node.position[2], + ], + }) } precision={2} restoreOnCommit={false} step={0.05} unit="m" - value={node.position[1] ?? 0} + value={roofOffset} /> - previewProp({ position: [node.position[0] ?? 0, node.position[1] ?? 0, v] }) - } - onCommit={(v) => - handleUpdate({ position: [node.position[0] ?? 0, node.position[1] ?? 0, v] }) - } + onChange={(v) => previewProp({ position: at(node.position[0], v) })} + onCommit={(v) => handleUpdate({ position: at(node.position[0], v) })} precision={2} restoreOnCommit={false} step={0.05} diff --git a/packages/nodes/src/turbine-vent/placement.test.ts b/packages/nodes/src/turbine-vent/placement.test.ts new file mode 100644 index 0000000000..d8707dbf28 --- /dev/null +++ b/packages/nodes/src/turbine-vent/placement.test.ts @@ -0,0 +1,54 @@ +import { expect, test } from 'bun:test' +import { RoofSegmentNode } from '@pascal-app/core' +import { getRoofOuterSurfaceFrameAtPoint } from '@pascal-app/viewer' +import { resolveTurbineVentPlacement, TurbineVentNode, turbineVentDefinition } from './index' + +const segment = RoofSegmentNode.parse({ width: 8, depth: 6, roofType: 'gable' }) +const vent = TurbineVentNode.parse(turbineVentDefinition.defaults()) + +test('placement snaps both coordinates and seats the vent on the finished roof', () => { + const placed = resolveTurbineVentPlacement(segment, vent, 0.13, 1.37, { + gridStep: 0.25, + alignDownslope: true, + }) + expect(placed.position[0]).toBe(0.25) + expect(placed.position[2]).toBe(1.25) + expect(placed.position[1]).toBeCloseTo( + getRoofOuterSurfaceFrameAtPoint(segment, 0.25, 1.25).point.y, + ) + expect(placed.valid).toBe(true) + expect(resolveTurbineVentPlacement(segment, vent, 0.13, 1.37).position[0]).toBe(0.13) +}) + +test('vents face downslope on opposite faces while moves preserve a custom rotation and roof offset', () => { + const front = resolveTurbineVentPlacement(segment, vent, 0, 1, { alignDownslope: true }) + const back = resolveTurbineVentPlacement(segment, vent, 0, -1, { alignDownslope: true }) + expect(Math.abs(front.rotation - back.rotation)).toBeCloseTo(Math.PI) + const moved = resolveTurbineVentPlacement(segment, { ...vent, rotation: 0.7 }, 1, 2, { + roofOffset: 0.1, + }) + expect(moved.rotation).toBe(0.7) + expect(moved.position[1]).toBeCloseTo( + getRoofOuterSurfaceFrameAtPoint(segment, 1, 2).point.y + 0.1, + ) +}) + +test('the whole flashing footprint must fit, including ridges, eaves and trimmed edges', () => { + expect(resolveTurbineVentPlacement(segment, vent, 0, 0).valid).toBe(false) + expect(resolveTurbineVentPlacement(segment, vent, 0, 3.5).valid).toBe(false) + const trimmed = RoofSegmentNode.parse({ ...segment, trim: { right: 2 } }) + expect(resolveTurbineVentPlacement(trimmed, vent, 1.9, 1).valid).toBe(false) + expect(resolveTurbineVentPlacement(trimmed, vent, 1.5, 1).valid).toBe(true) + const flat = RoofSegmentNode.parse({ + roofType: 'flat', + width: 8, + depth: 6, + overhang: 0, + gableOverhang: 0, + wallThickness: 0, + }) + expect(resolveTurbineVentPlacement(flat, { ...vent, baseOverhang: 0 }, 3.7, 0).valid).toBe(true) + expect(resolveTurbineVentPlacement(flat, { ...vent, baseOverhang: 0.2 }, 3.7, 0).valid).toBe( + false, + ) +}) diff --git a/packages/nodes/src/turbine-vent/placement.ts b/packages/nodes/src/turbine-vent/placement.ts new file mode 100644 index 0000000000..dcd4f816c0 --- /dev/null +++ b/packages/nodes/src/turbine-vent/placement.ts @@ -0,0 +1,44 @@ +import { difference, type Ring, type RoofSegmentNode, type TurbineVentNode } from '@pascal-app/core' +import { getRoofOuterSurfaceFrameAtPoint } from '@pascal-app/viewer' +import * as THREE from 'three' +import { roofFootprintInsideTrim } from '../shared/roof-footprint-trim' +import { + getDownSlopeYaw, + getRoofSurfaceFaceBoundsAt, + surfaceQuatFromNormal, +} from '../shared/roof-surface' + +export function resolveTurbineVentPlacement( + segment: RoofSegmentNode, + node: TurbineVentNode, + x: number, + z: number, + options: { gridStep?: number; roofOffset?: number; alignDownslope?: boolean } = {}, +) { + const step = options.gridStep ?? 0 + if (step > 0) { + x = Math.round(x / step) * step + z = Math.round(z / step) * step + } + const frame = getRoofOuterSurfaceFrameAtPoint(segment, x, z) + const rotation = options.alignDownslope ? getDownSlopeYaw(x, z, segment) : node.rotation + const quat = surfaceQuatFromNormal(frame.normal, new THREE.Quaternion()).multiply( + new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), rotation), + ) + const radius = Math.max(0.05, node.diameter / 2) + Math.max(0, node.baseOverhang) + const footprint: Ring = Array.from({ length: 32 }, (_, i) => { + const angle = (i * Math.PI * 2) / 32 + const px = radius * Math.cos(angle) + const pz = radius * Math.sin(angle) + const point = new THREE.Vector3(px, 0, pz).applyQuaternion(quat) + return [x + point.x, z + point.z] + }) + const face = getRoofSurfaceFaceBoundsAt(segment, x, z).polygon + return { + position: [x, frame.point.y + (options.roofOffset ?? 0), z] as [number, number, number], + rotation, + valid: + roofFootprintInsideTrim(segment, footprint) && + difference(footprint, face, { throwOnError: true }).length === 0, + } +} diff --git a/packages/nodes/src/turbine-vent/preview.tsx b/packages/nodes/src/turbine-vent/preview.tsx index 3c9b950d7e..f85cac532e 100644 --- a/packages/nodes/src/turbine-vent/preview.tsx +++ b/packages/nodes/src/turbine-vent/preview.tsx @@ -38,14 +38,9 @@ const TurbineVentPreview = ({ node, invalid }: { node: TurbineVentNode; invalid? const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry]) - useEffect( - () => () => { - geometry.dispose() - edgesGeometry.dispose() - material.dispose() - }, - [geometry, edgesGeometry, material], - ) + useEffect(() => () => geometry.dispose(), [geometry]) + useEffect(() => () => edgesGeometry.dispose(), [edgesGeometry]) + useEffect(() => () => material.dispose(), [material]) return ( @@ -57,7 +52,12 @@ const TurbineVentPreview = ({ node, invalid }: { node: TurbineVentNode; invalid? }} /> - + ) diff --git a/packages/nodes/src/turbine-vent/renderer.tsx b/packages/nodes/src/turbine-vent/renderer.tsx index 992bb9c8f6..6288fc6653 100644 --- a/packages/nodes/src/turbine-vent/renderer.tsx +++ b/packages/nodes/src/turbine-vent/renderer.tsx @@ -13,6 +13,7 @@ import { createMaterial, createMaterialFromPresetRef, createSurfaceRoleMaterial, + getRoofOuterSurfaceFrameAtPoint, resolveMaterialRef, useNodeEvents, useViewer, @@ -20,7 +21,7 @@ import { import { useFrame } from '@react-three/fiber' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' -import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' +import { surfaceQuatFromNormal } from '../shared/roof-surface' import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip' import { buildTurbineVentBase, buildTurbineVentHead } from './geometry' @@ -88,7 +89,7 @@ const TurbineVentRenderer = ({ node: storeNode }: { node: TurbineVentNode }) => const spin = node.spinSpeed ?? 0 useFrame((_, delta) => { if (headRef.current && spin !== 0) { - headRef.current.rotation.y += spin * delta + headRef.current.rotation.y = (headRef.current.rotation.y + spin * delta) % (Math.PI * 2) } }) @@ -96,7 +97,11 @@ const TurbineVentRenderer = ({ node: storeNode }: { node: TurbineVentNode }) => // normal the box-vent / solar-panel / skylight share. const surfaceQuat = useMemo(() => { if (!segment) return new THREE.Quaternion() - const normal = getAnalyticalNormal(node.position[0] ?? 0, node.position[2] ?? 0, segment) + const normal = getRoofOuterSurfaceFrameAtPoint( + segment, + node.position[0], + node.position[2], + ).normal return surfaceQuatFromNormal(normal, new THREE.Quaternion()) }, [segment, node.position[0], node.position[2]]) diff --git a/packages/nodes/src/turbine-vent/tool.test.tsx b/packages/nodes/src/turbine-vent/tool.test.tsx new file mode 100644 index 0000000000..8b26a14702 --- /dev/null +++ b/packages/nodes/src/turbine-vent/tool.test.tsx @@ -0,0 +1,93 @@ +import { expect, test } from 'bun:test' +import { + BuildingNode, + emitter, + LevelNode, + nodeRegistry, + RoofNode, + RoofSegmentNode, + registerNode, + sceneRegistry, + useScene, +} from '@pascal-app/core' +import { useEditor, usePlacementPreview } from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { act, create } from '@react-three/test-renderer' +import * as THREE from 'three' +import { buildTurbineVentGeometry, turbineVentDefinition } from './index' + +test('turbine-vent placement matches its preview, honors grid mode and blocks unsupported drops', async () => { + const restoreRegistry = nodeRegistry._snapshot() + if (!nodeRegistry.get('turbine-vent')) registerNode(turbineVentDefinition) + const savedEditor = useEditor.getState() + useEditor.setState({ mode: 'build', tool: 'turbine-vent', gridSnapStep: 0.25 }) + useEditor.getState().setSnappingMode('item', 'grid') + const savedScene = useScene.getState() + const savedSelection = useViewer.getState().selection + const building = BuildingNode.parse({}) + const level = LevelNode.parse({ parentId: building.id }) + const roof = RoofNode.parse({ parentId: level.id }) + const segment = RoofSegmentNode.parse({ parentId: roof.id, width: 20, depth: 20 }) + roof.children = [segment.id] + level.children = [roof.id] + building.children = [level.id] + useScene.setState({ + nodes: { [building.id]: building, [level.id]: level, [roof.id]: roof, [segment.id]: segment }, + rootNodeIds: [building.id], + dirtyNodes: new Set(), + readOnly: false, + }) + useViewer.getState().setSelection({ buildingId: building.id, levelId: level.id }) + sceneRegistry.nodes.set(segment.id, new THREE.Group()) + const { default: Tool } = await turbineVentDefinition.tool!() + const renderer = await create() + try { + const event = { + node: roof, + position: [0.13, 3, 3.13] as [number, number, number], + stopPropagation: () => {}, + } + await act(async () => emitter.emit('roof:move', event)) + const preview = usePlacementPreview.getState().node + expect(preview?.type).toBe('turbine-vent') + if (preview?.type !== 'turbine-vent') throw new Error('Missing turbine-vent preview') + await act(async () => emitter.emit('roof:click', event)) + const placed = Object.values(useScene.getState().nodes).find( + (node) => node.type === 'turbine-vent', + ) + expect(placed).toBeDefined() + if (placed?.type !== 'turbine-vent') throw new Error('Missing turbine vent') + expect(preview.position).toEqual(placed.position) + expect(placed.position[0]).toBe(0.25) + expect(placed.position[2]).toBe(3.25) + expect(placed.baseOverhang).toBe(0.05) + expect(preview.id).not.toBe(placed.id) + const previewGeometry = buildTurbineVentGeometry(preview)! + const placedGeometry = buildTurbineVentGeometry(placed)! + previewGeometry.computeBoundingBox() + placedGeometry.computeBoundingBox() + expect(previewGeometry.boundingBox!.equals(placedGeometry.boundingBox!)).toBe(true) + previewGeometry.dispose() + placedGeometry.dispose() + expect(usePlacementPreview.getState().node).toBeNull() + const outside = { ...event, position: [30, 3, 3] as [number, number, number] } + await act(async () => emitter.emit('roof:click', outside)) + expect( + Object.values(useScene.getState().nodes).filter((n) => n.type === 'turbine-vent'), + ).toHaveLength(1) + await act(async () => + emitter.emit('roof:click', { ...outside, nativeEvent: { altKey: true } as never }), + ) + expect( + Object.values(useScene.getState().nodes).filter((n) => n.type === 'turbine-vent'), + ).toHaveLength(2) + } finally { + restoreRegistry() + useEditor.setState(savedEditor) + await renderer.unmount() + sceneRegistry.nodes.delete(segment.id) + useScene.setState(savedScene) + useViewer.getState().setSelection(savedSelection) + usePlacementPreview.getState().clear() + } +}) diff --git a/packages/nodes/src/turbine-vent/tool.tsx b/packages/nodes/src/turbine-vent/tool.tsx index 0ecfdfb2a9..a9336cde6d 100644 --- a/packages/nodes/src/turbine-vent/tool.tsx +++ b/packages/nodes/src/turbine-vent/tool.tsx @@ -4,152 +4,166 @@ import { type AnyNodeId, emitter, type RoofEvent, - type RoofNode, - sceneRegistry, TurbineVentNode, 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 { + isGridSnapActive, + isMagneticSnapActive, + triggerSFX, + useEditor, + usePlacementPreview, +} from '@pascal-app/editor' +import { getRoofOuterSurfaceFrameAtPoint, useViewer } from '@pascal-app/viewer' +import { useEffect, useMemo, useState } from 'react' import * as THREE from 'three' +import { roofSegmentLocalToBuildingLocal } from '../shared/relative-roof-drag' import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' -import { getAnalyticalNormal, getDownSlopeYaw, surfaceQuatFromNormal } from '../shared/roof-surface' +import { surfaceQuatFromNormal } from '../shared/roof-surface' import { clearRoofSurfacePlacementGuides, - publishRoofSurfacePlacementGuides, - roofSurfaceFootprintFromNode, + publishRoofSurfaceNodePlacementGuides, + snapRoofSurfaceNodeTarget, } from '../shared/roof-surface-placement-guides' import { turbineVentDefinition } from './definition' +import { resolveTurbineVentPlacement } from './placement' import TurbineVentPreview from './preview' -const worldPoint = new THREE.Vector3() - -/** - * Turbine-vent placement tool. Mounts when the palette activates the - * turbine-vent kind; listens for `roof:*` events; on click commits a new - * `TurbineVentNode` parented to the targeted segment with segment-local - * coordinates. Mirrors the box-vent placement flow. - */ -const TurbineVentTool = () => { +export default function TurbineVentTool() { 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(null) - const [previewYaw, setPreviewYaw] = useState(0) - const [previewRotation, setPreviewRotation] = useState(0) - const lastSnapRef = useRef<[number, number] | null>(null) - - const previewNode = useMemo( - () => - TurbineVentNode.parse({ - ...turbineVentDefinition.defaults(), - name: 'Turbine Vent', - position: [0, 0, 0], - rotation: previewRotation, - }), - [previewRotation], + const seed = useMemo( + () => TurbineVentNode.parse({ ...turbineVentDefinition.defaults(), name: 'Turbine Vent' }), + [], ) + const [preview, setPreview] = useState<{ + node: TurbineVentNode + position: [number, number, number] + normal: THREE.Quaternion + yaw: number + valid: boolean + } | null>(null) 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 clear = () => { + setPreview(null) + if (usePlacementPreview.getState().node?.id === seed.id) + usePlacementPreview.getState().clear() + clearRoofSurfacePlacementGuides() } - - 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 (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== sx || prev[1] !== sz)) { - triggerSFX('sfx:grid-snap') - lastSnapRef.current = [sx, sz] + const resolve = (event: RoofEvent) => { + const hit = resolveRoofSegmentHit(event.node, ...event.position) + if (!hit) return null + const force = event.nativeEvent?.altKey === true + let placement = resolveTurbineVentPlacement(hit.segment, seed, hit.localX, hit.localZ, { + gridStep: !force && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0, + alignDownslope: true, + }) + if (!force && isMagneticSnapActive()) { + const target = snapRoofSurfaceNodeTarget({ + target: { + ...hit, + hit, + localX: placement.position[0], + localY: placement.position[1], + localZ: placement.position[2], + }, + node: { ...seed, rotation: placement.rotation }, + }) + placement = resolveTurbineVentPlacement(hit.segment, seed, target.localX, target.localZ, { + alignDownslope: true, + }) } - - 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)) - setPreviewRotation(getDownSlopeYaw(hit.localX, hit.localZ, hit.segment)) - setPreviewPos(worldToBuildingLocal(wx, wy, wz)) - publishRoofSurfacePlacementGuides({ - roof: event.node as RoofNode, + return { hit, placement, force } + } + const show = (event: RoofEvent) => { + const resolved = resolve(event) + if (!resolved) { + clear() + return + } + const { hit, placement, force } = resolved + const node = { + ...seed, + position: placement.position, + rotation: placement.rotation, + parentId: hit.segment.id, + roofSegmentId: hit.segment.id, + } + const normal = getRoofOuterSurfaceFrameAtPoint( + hit.segment, + placement.position[0], + placement.position[2], + ).normal + setPreview({ + node, + position: roofSegmentLocalToBuildingLocal(hit.segment.id, placement.position), + normal: surfaceQuatFromNormal(normal, new THREE.Quaternion()), + yaw: event.node.rotation + hit.segment.rotation, + valid: placement.valid || force, + }) + usePlacementPreview.getState().set(node, hit.segment) + publishRoofSurfaceNodePlacementGuides({ + roof: event.node, segment: hit.segment, - center: [hit.localX, hit.localY, hit.localZ], - footprint: roofSurfaceFootprintFromNode({ - ...previewNode, - rotation: getDownSlopeYaw(hit.localX, hit.localZ, hit.segment), - }), + center: placement.position, + node, }) 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 = TurbineVentNode.parse({ - ...turbineVentDefinition.defaults(), - name: 'Turbine Vent', + const place = (event: RoofEvent) => { + const resolved = resolve(event) + if (!resolved) return + const { hit, placement, force } = resolved + if (!placement.valid && !force) { + show(event) + return + } + const node = TurbineVentNode.parse({ + ...seed, + id: undefined, roofSegmentId: hit.segment.id, - position: [hit.localX, hit.localY, hit.localZ], - rotation: getDownSlopeYaw(hit.localX, hit.localZ, hit.segment), + position: placement.position, + rotation: placement.rotation, }) - state.createNode(vent, hit.segment.id as AnyNodeId) - state.dirtyNodes.add(hit.segment.id as AnyNodeId) - setSelection({ selectedIds: [vent.id] }) + useScene.getState().createNode(node, hit.segment.id as AnyNodeId) + useViewer.getState().setSelection({ selectedIds: [node.id] }) triggerSFX('sfx:item-place') - clearRoofSurfacePlacementGuides() + clear() event.stopPropagation() } - - emitter.on('roof:move', updatePreview) - emitter.on('roof:enter', updatePreview) - emitter.on('roof:click', onClick) - + emitter.on('roof:move', show) + emitter.on('roof:enter', show) + emitter.on('roof:leave', clear) + emitter.on('roof:click', place) + emitter.on('tool:cancel', clear) return () => { - emitter.off('roof:move', updatePreview) - emitter.off('roof:enter', updatePreview) - emitter.off('roof:click', onClick) - clearRoofSurfacePlacementGuides() + emitter.off('roof:move', show) + emitter.off('roof:enter', show) + emitter.off('roof:leave', clear) + emitter.off('roof:click', place) + emitter.off('tool:cancel', clear) + clear() } - }, [activeBuildingId, setSelection, previewNode]) + }, [activeBuildingId, seed]) return ( <> } + ghost={} onInvalidTarget={() => { - setPreviewPos(null) - setPreviewSurfaceQuat(null) + setPreview(null) + usePlacementPreview.getState().clear() clearRoofSurfacePlacementGuides() }} /> - {activeBuildingId && previewPos && previewSurfaceQuat && ( - - - - + {preview && ( + + + + @@ -157,5 +171,3 @@ const TurbineVentTool = () => { ) } - -export default TurbineVentTool diff --git a/wiki/design/box-vent-thumbnails.md b/wiki/design/box-vent-thumbnails.md new file mode 100644 index 0000000000..ed7e3f0137 --- /dev/null +++ b/wiki/design/box-vent-thumbnails.md @@ -0,0 +1,16 @@ +# Box vent style thumbnails + +Generated with the built-in image generation tool using isolated roof-mounted Box, Cap, and Dome screenshots as geometry references and `apps/editor/public/icons/box-vent.webp` as the visual-style reference. Final assets are transparent 256×256 WebP files in `apps/editor/public/icons/box-vent/`. + +## box + +Generate one transparent square architectural editor style-picker thumbnail. Reference image 1 is an actual box vent screenshot: use ONLY the single white vent on the roof as geometry reference, omit all editor UI and the roof. Reference image 2 is the established visual style ONLY, not its geometry. Depict a plain flat rectangular box hood on a narrower inset short rectangular pedestal and thin square flashing plate; no louvers. Faithful simple silhouette, no invented screws, grilles, pipes, fins or extra parts. Pale lavender clay material, pale lilac tops and deeper purple recesses, soft studio light upper left, ambient occlusion. Isometric three-quarter product view, level flashing plate, centered whole object fills 80% square with consistent margin. Transparent background, no floor, no roof, no text, no watermark. Match reference 2's polished miniature icon treatment. + +## cap + +Generate one transparent square architectural editor style-picker thumbnail. Reference image 1 is an actual cap vent screenshot: use ONLY the single white vent on the roof as geometry reference, omit all editor UI and the roof. Reference image 2 is the established visual style ONLY, not its geometry. Depict a low square truncated pyramidal cap with a flat small square top and four sloped faces, narrow dark recessed opening above a short rectangular base, on a thin square flashing plate. Faithful simple silhouette, no invented screws, grilles, pipes, fins or extra parts. Pale lavender clay material, pale lilac tops and deeper purple recesses, soft studio light upper left, ambient occlusion. Isometric three-quarter product view, level flashing plate, centered whole object fills 80% square with consistent margin. Transparent background, no floor, no roof, no text, no watermark. Match reference 2's polished miniature icon treatment. + +## dome + +Generate one transparent square architectural editor style-picker thumbnail. Reference image 1 is an actual dome vent screenshot: use ONLY the single white vent on the roof as geometry reference, omit all editor UI and the roof. Reference image 2 is the established visual style ONLY, not its geometry. Depict a smooth low rounded circular dome hood on a short circular base, on a thin square flashing plate. Faithful simple silhouette, no invented screws, grilles, pipes, fins or extra parts. Pale lavender clay material, pale lilac tops and deeper purple recesses, soft studio light upper left, ambient occlusion. Isometric three-quarter product view, level flashing plate, centered whole object fills 80% square with consistent margin. Transparent background, no floor, no roof, no text, no watermark. Match reference 2's polished miniature icon treatment. + diff --git a/wiki/design/canopy-thumbnails.md b/wiki/design/canopy-thumbnails.md new file mode 100644 index 0000000000..e6c3ab1ae8 --- /dev/null +++ b/wiki/design/canopy-thumbnails.md @@ -0,0 +1,21 @@ +# Canopy style thumbnails + +Generated with the built-in imagegen tool from editor screenshots and the existing canopy catalog icon. Final assets are transparent 256×256 WebP, encoded with cwebp quality 85. + +## mono + +Reference: editor screenshot `canopy-mono-reference.png`; catalog style: `apps/editor/public/icons/lean-to-extension.webp`. + +Use case: stylized-concept. Asset type: small editor style thumbnail. Create one isolated freestanding canopy with a single plane mono-pitch roof, one high side and one low side. Image 1 is the actual editor model reference: retain its roof shape, slim square posts, beams and diagonal knee braces. Image 2 is the catalog style reference: match its soft lavender-purple materials, clean miniature architectural 3D rendering and soft highlights. Show the entire canopy from a three-quarter elevated orthographic view with the roof profile clearly visible; center with generous padding for a square thumbnail. Simplify tiny details for legibility at 64 pixels. Transparent background, no floor, no UI, selection handles, text, labels, border, other objects or watermark. All posts and braces must connect cleanly to their beams. + +## gable + +Reference: editor screenshot `canopy-gable-reference.png`; catalog style: `apps/editor/public/icons/lean-to-extension.webp`. + +Use case: stylized-concept. Asset type: small editor style thumbnail. Create one isolated freestanding canopy with two roof planes meeting at a raised central ridge, a clear inverted V gable profile. Image 1 is the actual editor model reference: retain its roof shape, slim square posts, beams and diagonal knee braces. Image 2 is the catalog style reference: match its soft lavender-purple materials, clean miniature architectural 3D rendering and soft highlights. Show the entire canopy from a three-quarter elevated orthographic view with the roof profile clearly visible; center with generous padding for a square thumbnail. Simplify tiny details for legibility at 64 pixels. Transparent background, no floor, no UI, selection handles, text, labels, border, other objects or watermark. All posts and braces must connect cleanly to their beams. + +## butterfly + +Reference: editor screenshot `canopy-butterfly-reference.png`; catalog style: `apps/editor/public/icons/lean-to-extension.webp`. + +Use case: stylized-concept. Asset type: small editor style thumbnail. Create one isolated freestanding canopy with two roof planes sloping inward to a LOW central valley and two HIGH outer eaves, a clear shallow V butterfly profile. Image 1 is the actual editor model reference: retain its roof shape, slim square posts, beams and diagonal knee braces. Image 2 is the catalog style reference: match its soft lavender-purple materials, clean miniature architectural 3D rendering and soft highlights. Show the entire canopy from a three-quarter elevated orthographic view with the roof profile clearly visible; center with generous padding for a square thumbnail. Simplify tiny details for legibility at 64 pixels. Transparent background, no floor, no UI, selection handles, text, labels, border, other objects or watermark. All posts and braces must connect cleanly to their beams. diff --git a/wiki/design/chimney-thumbnails.md b/wiki/design/chimney-thumbnails.md new file mode 100644 index 0000000000..fbecd10bcf --- /dev/null +++ b/wiki/design/chimney-thumbnails.md @@ -0,0 +1,27 @@ +# Chimney style thumbnails + +Generated with the built-in imagegen tool from one roof-mounted editor capture per style on 2026-10-22. The existing chimney icon supplied only material/lighting guidance. Final files: `apps/editor/public/icons/chimney/{brick,modern,round}.webp`, transparent 256 × 256, quality 90 and alpha quality 100. + +## brick + +Reference capture: `/tmp/chimney-brick-reference.png`. + +Exact prompt: + +Use case: stylized-concept. Asset: architectural editor style thumbnail. Reference image 1 supplies exact chimney geometry and proportions; reference image 2 supplies lavender clay material and icon finish only. Create an isolated chimney matching the white brick model in screenshot 1. Square straight masonry shaft, two narrow projecting horizontal bands near the upper third, a thin flat overhanging square crown, and exactly ONE hollow round flue pot with a lip. Keep body smooth like the screenshot, no invented brick texture. Tall slender body about 3 times its width. Remove roof and interface; show complete chimney with a clean flat bottom. Isometric three-quarter view, centered at 80 percent of square canvas, soft upper-left studio light, crisp silhouette, subtle ambient occlusion. Transparent background. No text, UI, roof, smoke, base plinth, extra pots or decorative additions. + +## modern + +Reference capture: `/tmp/chimney-modern-reference.png`. + +Exact prompt: + +Use case: stylized-concept. Asset: architectural editor style thumbnail. Reference image 1 supplies exact chimney geometry and proportions; reference image 2 supplies lavender clay material and icon finish only. Create an isolated chimney matching the white modern model in screenshot 1. Square straight shaft with one long shallow recessed rectangular panel on each visible face, no bands, a very thin flat square crown, exactly ONE small hollow round flue pot. Tall slender body about 3 times its width. Remove roof and interface; show complete chimney with a clean flat bottom. Isometric three-quarter view, centered at 80 percent of square canvas, soft upper-left studio light, crisp silhouette, subtle ambient occlusion. Transparent background. No text, UI, roof, smoke, base plinth, extra pots or decorative additions. + +## round + +Reference capture: `/tmp/chimney-round-reference.png`. + +Exact prompt: + +Use case: stylized-concept. Asset: architectural editor style thumbnail. Reference image 1 supplies exact chimney geometry and proportions; reference image 2 supplies lavender clay material and icon finish only. Create an isolated chimney matching the white round model in screenshot 1. Smooth cylindrical vertical shaft, one narrow projecting circular band near the upper third, a thin circular overhanging crown disk and exactly ONE small hollow round flue pot centered on it. No square elements. Tall slender body about 3 times its width. Remove roof and interface; show complete chimney with a clean flat bottom. Isometric three-quarter view, centered at 80 percent of square canvas, soft upper-left studio light, crisp silhouette, subtle ambient occlusion. Transparent background. No text, UI, roof, smoke, base plinth, extra pots or decorative additions. diff --git a/wiki/design/cupola-thumbnails.md b/wiki/design/cupola-thumbnails.md new file mode 100644 index 0000000000..90811ca3db --- /dev/null +++ b/wiki/design/cupola-thumbnails.md @@ -0,0 +1,12 @@ +# Cupola style thumbnails + +Generated with the built-in image generation tool from isolated editor captures of each cupola style on a gable roof. The existing cupola icon supplied the visual style. Final assets are transparent 256×256 WebP files in `apps/editor/public/icons/cupola/`, converted with Sharp at quality 90 and alpha quality 100. + +## dome + +Use case: stylized-concept. Create one square transparent architectural editor thumbnail. Image 1 is a screenshot of the actual dome cupola on a roof: use ONLY the small white cupola as geometry reference. Image 2 supplies the established lavender clay icon style ONLY. Subject: a square louvered roof lantern, upright, four framed recessed panels with five horizontal slats per face, square corner posts, stepped base trim and a tiered overhanging square cornice. Top is a smooth rounded hemispherical dome, sitting on the square cornice; distinctly curved roof silhouette, NOT pyramid. A slim vertical post topped by a small round ball at the apex. The base has a roof-fitting inverted V notch across its underside, matching its ridge mounting in screenshot; omit the large building roof itself. Proportions: body width and depth 0.8m, total cupola height 1m above its mounting base. Pale lavender clay, pale lilac lit faces, deeper violet louver recesses; soft upper-left studio light and ambient occlusion. Isometric three-quarter view, entire object centered occupying 80% of square, generous transparent margin. Genuine transparent background. No UI, text, watermark, roof platform, decorative spire, extra windows or extra hardware. Maintain the actual screenshot silhouette and details. + +## pyramid + +Use case: stylized-concept. Create one square transparent architectural editor thumbnail. Image 1 is a screenshot of the actual pyramid cupola on a roof: use ONLY the small white cupola as geometry reference. Image 2 supplies the established lavender clay icon style ONLY. Subject: a square louvered roof lantern, upright, four framed recessed panels with five horizontal slats per face, square corner posts, stepped base trim and a tiered overhanging square cornice. Top is a four-sided pyramid roof with straight triangular slopes and sharp corner seams; NOT dome. A slim vertical post topped by a small round ball at the apex. The base has a roof-fitting inverted V notch across its underside, matching its ridge mounting in screenshot; omit the large building roof itself. Proportions: body width and depth 0.8m, total cupola height 1m above its mounting base. Pale lavender clay, pale lilac lit faces, deeper violet louver recesses; soft upper-left studio light and ambient occlusion. Isometric three-quarter view, entire object centered occupying 80% of square, generous transparent margin. Genuine transparent background. No UI, text, watermark, roof platform, decorative spire, extra windows or extra hardware. Maintain the actual screenshot silhouette and details. + diff --git a/wiki/design/dormer-thumbnails.md b/wiki/design/dormer-thumbnails.md new file mode 100644 index 0000000000..5204614093 --- /dev/null +++ b/wiki/design/dormer-thumbnails.md @@ -0,0 +1,75 @@ +# Dormer style thumbnails + +Generated with the built-in imagegen tool from actual editor screenshots of each dormer on a roof. The existing dormer icon supplied the lavender clay style; the final Dutch thumbnail used the generated hip icon as its style reference. Gambrel and Dutch were regenerated to preserve their distinct roof silhouettes. + +Delivered as 256 × 256 transparent WebP, converted with Sharp (quality 90, alpha quality 100). + +## gable + +Asset: `apps/editor/public/icons/dormer/gable.webp` + +Geometry reference: `/tmp/dormer-gable-reference.png` + +Final prompt: + +Use case: style-transfer. Generate one square building-editor style thumbnail. Image 1 is an actual gable dormer placed on a roof: use the dormer itself as the geometry reference, including its gable roof profile, projecting eave trim, upright walls, single four-pane front window and sill. Image 2 is the existing lavender dormer icon: match its clean soft-lit clay render, palette, framing and isometric three-quarter angle. Isolate only the small dormer, not the large host roof or building. Center the complete dormer at 80% of the image with transparent background. Keep the distinctive gable roof shape from screenshot, simplify the bottom to a clean cutout edge as in image 2. Lavender walls and roof, subtly darker lavender glazing. No UI, text, outlines, roof scene, pedestal, watermark or extra objects. + +## hip + +Asset: `apps/editor/public/icons/dormer/hip.webp` + +Geometry reference: `/tmp/dormer-hip-reference.png` + +Final prompt: + +Use case: style-transfer. Generate one square building-editor style thumbnail. Image 1 is an actual hip dormer placed on a roof: use the dormer itself as the geometry reference, including its hip roof profile, projecting eave trim, upright walls, single four-pane front window and sill. Image 2 is the existing lavender dormer icon: match its clean soft-lit clay render, palette, framing and isometric three-quarter angle. Isolate only the small dormer, not the large host roof or building. Center the complete dormer at 80% of the image with transparent background. Keep the distinctive hip roof shape from screenshot, simplify the bottom to a clean cutout edge as in image 2. Lavender walls and roof, subtly darker lavender glazing. No UI, text, outlines, roof scene, pedestal, watermark or extra objects. + +## shed + +Asset: `apps/editor/public/icons/dormer/shed.webp` + +Geometry reference: `/tmp/dormer-shed-reference.png` + +Final prompt: + +Use case: style-transfer. Generate one square building-editor style thumbnail. Image 1 is an actual shed dormer placed on a roof: use the dormer itself as the geometry reference, including its shed roof profile, projecting eave trim, upright walls, single four-pane front window and sill. Image 2 is the existing lavender dormer icon: match its clean soft-lit clay render, palette, framing and isometric three-quarter angle. Isolate only the small dormer, not the large host roof or building. Center the complete dormer at 80% of the image with transparent background. Keep the distinctive shed roof shape from screenshot, simplify the bottom to a clean cutout edge as in image 2. Lavender walls and roof, subtly darker lavender glazing. No UI, text, outlines, roof scene, pedestal, watermark or extra objects. + +## gambrel + +Asset: `apps/editor/public/icons/dormer/gambrel.webp` + +Geometry reference: `/tmp/dormer-gambrel-reference.png` + +Final prompt: + +Use case: style-transfer. Create a lavender clay isometric GAMBREL dormer thumbnail, with a clearly visible barn-roof profile: each of the TWO roof sides has TWO different slopes, a steep lower slope and a shallow upper slope, separated by a crisp horizontal kink running front to back. The front roof outline is a five-sided barn shape, NOT a triangular gable. Image 1 is the actual gambrel dormer screenshot; preserve its window, sill, upright walls and double-pitch roof construction. Image 2 supplies only the lavender clay icon aesthetic; DO NOT copy its simple triangular gable roof. One centered complete dormer, 80% framing, soft studio light, four-pane front window, transparent background. No host roof, UI, text, scenery, pedestal or watermark. + +## dutch + +Asset: `apps/editor/public/icons/dormer/dutch.webp` + +Geometry reference: `/tmp/dormer-dutch-reference.png` + +Final prompt: + +Create a transparent lavender clay icon of the DUTCH dormer in image 1. The roof MUST have a broad LOWER HIPPED SKIRT on all four sides, with a much SMALLER UPPER GABLE roof rising out of the middle. At the front, there is a sloping trapezoidal hip surface ABOVE the window and BELOW the small triangular gablet. The upper gable is recessed significantly behind the front wall, not aligned flush with it. Preserve this two-stage compound roof silhouette exactly as shown in image 1. One four-pane window and sill on the front wall. Image 2 is ONLY a color/material/lighting reference, not a shape reference. Square centered product icon, soft lavender clay, 80% framing, transparent background. No host roof, UI, text, pedestal. Do not produce a simple triangular gable roof. + +## mansard + +Asset: `apps/editor/public/icons/dormer/mansard.webp` + +Geometry reference: `/tmp/dormer-mansard-reference.png` + +Final prompt: + +Use case: style-transfer. Generate one square building-editor style thumbnail. Image 1 is an actual mansard dormer placed on a roof: use the dormer itself as the geometry reference, including its mansard roof profile, projecting eave trim, upright walls, single four-pane front window and sill. Image 2 is the existing lavender dormer icon: match its clean soft-lit clay render, palette, framing and isometric three-quarter angle. Isolate only the small dormer, not the large host roof or building. Center the complete dormer at 80% of the image with transparent background. Keep the distinctive mansard roof shape from screenshot, simplify the bottom to a clean cutout edge as in image 2. Lavender walls and roof, subtly darker lavender glazing. No UI, text, outlines, roof scene, pedestal, watermark or extra objects. + +## flat + +Asset: `apps/editor/public/icons/dormer/flat.webp` + +Geometry reference: `/tmp/dormer-flat-reference.png` + +Final prompt: + +Use case: style-transfer. Generate one square building-editor style thumbnail. Image 1 is an actual flat dormer placed on a roof: use the dormer itself as the geometry reference, including its flat roof profile, projecting eave trim, upright walls, single four-pane front window and sill. Image 2 is the existing lavender dormer icon: match its clean soft-lit clay render, palette, framing and isometric three-quarter angle. Isolate only the small dormer, not the large host roof or building. Center the complete dormer at 80% of the image with transparent background. Keep the distinctive flat roof shape from screenshot, simplify the bottom to a clean cutout edge as in image 2. Lavender walls and roof, subtly darker lavender glazing. No UI, text, outlines, roof scene, pedestal, watermark or extra objects. diff --git a/wiki/design/downspout-thumbnails.md b/wiki/design/downspout-thumbnails.md new file mode 100644 index 0000000000..b8958655b2 --- /dev/null +++ b/wiki/design/downspout-thumbnails.md @@ -0,0 +1,35 @@ +# Downspout thumbnails + +Generated with the built-in imagegen tool from editor screenshots and the existing downspout catalog icon. Final assets: transparent 256 × 256 WebP, apps/editor/public/icons/downspout/. + +## round + +Reference: /tmp/downspout-round-reference.png + +Use case: stylized-concept. Square transparent UI thumbnail for downspout style round. First input is the editor screenshot reference: use ONLY the white downspout on the left-front building wall, omitting the building, roof, gutter, UI and all other objects. Second image is our existing catalog icon: match its lavender purple smooth 3D miniature material and soft studio highlights. Depict a circular round pipe with round bands, upper offset elbows and bottom kickout, no splash block. Orthographic three-quarter view with clear visible cross section, centered with transparent padding, crisp silhouette at small size. Preserve actual model routing and restrained hardware. No labels, text, watermark, scene background, ground plane or invented details. + +## rect + +Reference: /tmp/downspout-rect-reference.png + +Use case: stylized-concept. Square transparent UI thumbnail for downspout style rect. First input is the editor screenshot reference: use ONLY the white downspout on the left-front building wall, omitting the building, roof, gutter, UI and all other objects. Second image is our existing catalog icon: match its lavender purple smooth 3D miniature material and soft studio highlights. Depict a rectangular pipe with flat rectangular bands, upper offset elbows and bottom kickout, no splash block. Orthographic three-quarter view with clear visible cross section, centered with transparent padding, crisp silhouette at small size. Preserve actual model routing and restrained hardware. No labels, text, watermark, scene background, ground plane or invented details. + +Correction: Edit this lavender downspout UI icon. Keep its transparent background, exact material, camera, main vertical rectangular pipe, mounting bands, and bottom kickout. Fix only the top: replace the malformed hollow cube with a normal continuous rectangular offset neck. It must have ONE opening at its very top, facing upward to receive water from a gutter, then a short vertical neck, a 45 degree diagonal offset leading into the main vertical pipe. No horizontal-facing upper opening, no tee fitting, no second inlet, no cube with two open faces. Keep the complete object centered with transparent padding. + +## splash + +Reference: /tmp/downspout-splash-reference.png + +Use case: stylized-concept. Square transparent UI thumbnail for downspout style splash. First input is the editor screenshot reference: use ONLY the white downspout on the left-front building wall, omitting the building, roof, gutter, UI and all other objects. Second image is our existing catalog icon: match its lavender purple smooth 3D miniature material and soft studio highlights. Depict a close-up of the bottom third of a rectangular downspout, a short outward kickout elbow with open mouth discharging onto a sloped splash block with raised side rims. Orthographic three-quarter view with clear visible cross section, centered with transparent padding, crisp silhouette at small size. Preserve actual model routing and restrained hardware. No labels, text, watermark, scene background, ground plane or invented details. + +## kickout + +Reference: /tmp/downspout-kickout-reference.png + +Use case: stylized-concept. Square transparent UI thumbnail for downspout style kickout. First input is the editor screenshot reference: use ONLY the white downspout on the left-front building wall, omitting the building, roof, gutter, UI and all other objects. Second image is our existing catalog icon: match its lavender purple smooth 3D miniature material and soft studio highlights. Depict a close-up of the bottom third of a rectangular downspout with an outward kickout elbow and visible open mouth, NO splash block. Orthographic three-quarter view with clear visible cross section, centered with transparent padding, crisp silhouette at small size. Preserve actual model routing and restrained hardware. No labels, text, watermark, scene background, ground plane or invented details. + +## straight + +Reference: /tmp/downspout-straight-reference.png + +Use case: stylized-concept. Square transparent UI thumbnail for downspout style straight. First input is the editor screenshot reference: use ONLY the white downspout on the left-front building wall, omitting the building, roof, gutter, UI and all other objects. Second image is our existing catalog icon: match its lavender purple smooth 3D miniature material and soft studio highlights. Depict a close-up of the bottom third of a rectangular downspout ending in a straight vertical open pipe mouth, NO kickout elbow and NO splash block. Orthographic three-quarter view with clear visible cross section, centered with transparent padding, crisp silhouette at small size. Preserve actual model routing and restrained hardware. No labels, text, watermark, scene background, ground plane or invented details. \ No newline at end of file diff --git a/wiki/design/eyebrow-vent-thumbnails.md b/wiki/design/eyebrow-vent-thumbnails.md new file mode 100644 index 0000000000..af6af2221d --- /dev/null +++ b/wiki/design/eyebrow-vent-thumbnails.md @@ -0,0 +1,27 @@ +# Eyebrow vent style thumbnails + +Generated from individual roof-mounted editor captures on 2026-10-22 using the imagegen skill. Assets are transparent 256 × 256 WebP, quality 90, alpha quality 100. The existing eyebrow vent icon supplied the lavender material reference for half-round and slant-box. Scoop was regenerated with only its scene capture to preserve its tapered outline. + +## scoop + +Reference: `/tmp/eyebrow-scoop-reference.png`. + +Exact prompt: + +Generate a transparent lavender clay 3D icon of the small white scoop eyebrow roof vent in this screenshot. The screenshot is geometry reference. Its plan shape is a teardrop: wide half-ellipse arched mouth at the front and BOTH width and height taper smoothly to a single low rounded POINT at the rear. It is NOT a barrel or tunnel with parallel sides. Show the object alone from front three-quarter isometric angle, with its pointed tail clearly visible behind the arched louvered mouth. Exactly three horizontal slats in the framed recessed mouth. Width 0.5, depth 0.6, height 0.3. Soft lavender material, soft upper-left studio lighting, crisp simple clay product icon matching an architectural editor library. No glow, floor, roof, text, UI, flashing flange or extra objects. Transparent background, centered 80 percent canvas. + +## half-round + +Reference: `/tmp/eyebrow-half-round-reference.png`. + +Exact prompt: + +Create a single 3D product thumbnail of the eyebrow roof vent shown in the first reference screenshot. A constant half-elliptical arched cross-section extruded over the entire depth, ending in a flat closed rear. Parallel sides; absolutely no taper to a point. Preserve its proportions: width 0.50, depth 0.60, height 0.30. Show a clearly framed recessed front opening with exactly three horizontal louver blades and a dark recessed screen behind them. No projecting flashing plate, no extra accessories. Use the second reference only for soft lavender clay material, polished icon lighting and visual finish, never its geometry. Isolate the vent from the roof and editor interface. Front three-quarter isometric view with the front opening prominently visible, soft upper-left studio light, subtle ambient occlusion, centered at 80 percent of canvas. Transparent background, no roof, text, labels, border or UI. + +## slant-box + +Reference: `/tmp/eyebrow-slant-box-reference.png`. + +Exact prompt: + +Create a single 3D product thumbnail of the eyebrow roof vent shown in the first reference screenshot. A rectangular front opening, straight vertical side walls and a planar top sloping down to a rear wall at half front height. Angular wedge box; no curved arch. Preserve its proportions: width 0.50, depth 0.60, height 0.30. Show a clearly framed recessed front opening with exactly three horizontal louver blades and a dark recessed screen behind them. No projecting flashing plate, no extra accessories. Use the second reference only for soft lavender clay material, polished icon lighting and visual finish, never its geometry. Isolate the vent from the roof and editor interface. Front three-quarter isometric view with the front opening prominently visible, soft upper-left studio light, subtle ambient occlusion, centered at 80 percent of canvas. Transparent background, no roof, text, labels, border or UI. diff --git a/wiki/design/gutter-thumbnails.md b/wiki/design/gutter-thumbnails.md new file mode 100644 index 0000000000..8e3dd13c19 --- /dev/null +++ b/wiki/design/gutter-thumbnails.md @@ -0,0 +1,21 @@ +# Gutter thumbnails + +Generated with the built-in imagegen tool using roof-mounted editor screenshots and the existing gutter catalog icon. Final assets are transparent 256 × 256 WebP files in apps/editor/public/icons/gutter/. + +## k-style + +Reference: /tmp/gutter-k-style-reference.png + +Use case: stylized-concept. Create a square transparent-background UI style thumbnail for a k-style rain gutter. Image 1 is a screenshot reference of the actual gutter mounted along the lower left roof eave: extract only that gutter, omit the roof, dormer opening, app UI and all other objects. Image 2 is the existing gutter catalog icon and is the style and camera reference. Match its lavender-purple smooth 3D miniature material, soft studio lighting, diagonal lower-left to upper-right orientation, orthographic three-quarter view from above, centered composition with generous transparent padding. Show a short open-topped K-style ogee gutter with a sculpted S-curve front fascia and folded lip, capped ends and a few slim straps across its open channel. Clearly distinguish the cross section. No downspout, no outlet holes, no text, no watermark, no background or ground shadow plane. + +## half-round + +Reference: /tmp/gutter-half-round-reference.png + +Use case: stylized-concept. Create a square transparent-background UI style thumbnail for a half-round rain gutter. Image 1 is a screenshot reference of the actual gutter mounted along the lower left roof eave: extract only that gutter, omit the roof, dormer opening, app UI and all other objects. Image 2 is the existing gutter catalog icon and is the style and camera reference. Match its lavender-purple smooth 3D miniature material, soft studio lighting, diagonal lower-left to upper-right orientation, orthographic three-quarter view from above, centered composition with generous transparent padding. Show a short open-topped semicircular trough with rounded front rim and semicircular end caps, capped ends and a few slim straps across its open channel. Clearly distinguish the cross section. No downspout, no outlet holes, no text, no watermark, no background or ground shadow plane. + +## box + +Reference: /tmp/gutter-box-reference.png + +Use case: stylized-concept. Create a square transparent-background UI style thumbnail for a box rain gutter. Image 1 is a screenshot reference of the actual gutter mounted along the lower left roof eave: extract only that gutter, omit the roof, dormer opening, app UI and all other objects. Image 2 is the existing gutter catalog icon and is the style and camera reference. Match its lavender-purple smooth 3D miniature material, soft studio lighting, diagonal lower-left to upper-right orientation, orthographic three-quarter view from above, centered composition with generous transparent padding. Show a short open-topped square U-channel with straight vertical sides, flat floor and folded front lip, capped ends and a few slim straps across its open channel. Clearly distinguish the cross section. No downspout, no outlet holes, no text, no watermark, no background or ground shadow plane. \ No newline at end of file diff --git a/wiki/design/ridge-vent-thumbnails.md b/wiki/design/ridge-vent-thumbnails.md new file mode 100644 index 0000000000..c9d365bf54 --- /dev/null +++ b/wiki/design/ridge-vent-thumbnails.md @@ -0,0 +1,16 @@ +# Ridge vent style thumbnails + +Generated with the built-in image generation tool from isolated ridge-vent roof screenshots and the existing ridge vent icon as the visual-style reference. Final assets are transparent 256×256 WebP files in `apps/editor/public/icons/ridge-vent/`. + +## standard + +Generate one square transparent architectural editor UI thumbnail. Image 1 is the actual standard ridge vent on a roof: use only the short raised dark vent strip at the ridge as geometry reference; omit the large roof and all UI. Image 2 is the established lavender thumbnail style reference ONLY. Subject is one long narrow ridge vent cap, length about 6 times width, a smooth rounded arched cap cross section with continuous unbroken top, no courses or ribs. Keep roof-conforming inverted-V underside at the visible near end. Closed cap thickness at ends, no feet, brackets, roof platform, extra grilles or screws. Pale lavender clay, pale lilac highlights, deeper purple recesses; soft studio light upper left, ambient occlusion. Isometric three-quarter view matching reference 2, long axis diagonally lower-left to upper-right, entire object centered occupying 85% image width, generous transparent margins. Clean refined miniature render readable at 64px. Genuine transparent background. No text, watermark, floor, roof or UI. + +## shingled + +Generate one square transparent architectural editor UI thumbnail. Image 1 is the actual shingled ridge vent on a roof: use only the short raised dark vent strip at the ridge as geometry reference; omit the large roof and all UI. Image 2 is the established lavender thumbnail style reference ONLY. Subject is one long narrow ridge vent cap, length about 6 times width, a peaked folded cap with a narrow crown, six evenly spaced raised transverse shingle-course seams following both slopes. Keep roof-conforming inverted-V underside at the visible near end. Closed cap thickness at ends, no feet, brackets, roof platform, extra grilles or screws. Pale lavender clay, pale lilac highlights, deeper purple recesses; soft studio light upper left, ambient occlusion. Isometric three-quarter view matching reference 2, long axis diagonally lower-left to upper-right, entire object centered occupying 85% image width, generous transparent margins. Clean refined miniature render readable at 64px. Genuine transparent background. No text, watermark, floor, roof or UI. + +## metal + +Generate one square transparent architectural editor UI thumbnail. Image 1 is the actual metal ridge vent on a roof: use only the short raised dark vent strip at the ridge as geometry reference; omit the large roof and all UI. Image 2 is the established lavender thumbnail style reference ONLY. Subject is one long narrow ridge vent cap, length about 6 times width, a folded metal cap with a broad flat crown, two sloped shoulders, small doubled edge hems along both long edges, no transverse shingle seams. Keep roof-conforming inverted-V underside at the visible near end. Closed cap thickness at ends, no feet, brackets, roof platform, extra grilles or screws. Pale lavender clay, pale lilac highlights, deeper purple recesses; soft studio light upper left, ambient occlusion. Isometric three-quarter view matching reference 2, long axis diagonally lower-left to upper-right, entire object centered occupying 85% image width, generous transparent margins. Clean refined miniature render readable at 64px. Genuine transparent background. No text, watermark, floor, roof or UI. + diff --git a/wiki/design/skylight-thumbnails.md b/wiki/design/skylight-thumbnails.md new file mode 100644 index 0000000000..955330cb4f --- /dev/null +++ b/wiki/design/skylight-thumbnails.md @@ -0,0 +1,53 @@ +# Skylight style thumbnails + +Generated with the built-in imagegen tool using one actual editor roof screenshot per style and `apps/editor/public/icons/skylight.webp` as the visual style reference. Delivered as 256 × 256 transparent WebP images, converted with Sharp (quality 90, alpha quality 100). + +## flat + +Asset: `apps/editor/public/icons/skylight/flat.webp` + +Reference capture: `/tmp/skylight-flat-reference.png` + +Prompt: + +Use case: style-transfer. Create one square product thumbnail for a building editor style picker. Image 1 is the actual skylight geometry reference captured on a roof; isolate only that skylight. Image 2 is the existing icon style reference: match its lavender clay material, soft studio lighting and clean isometric product rendering. Subject: A closed flat glazed skylight with a raised rectangular curb, stepped rim and thin flashing apron. Preserve the geometry details and proportions shown in image 1, but use the camera presentation and lavender palette of image 2. Center one complete object filling 80% of the square. Glass should be subtly translucent lavender with readable frame edges. Truly transparent background. Remove all roof, building, UI, text, selection outlines, logos, scenery and watermark. No pedestal. + +## walk-on + +Asset: `apps/editor/public/icons/skylight/walk-on.webp` + +Reference capture: `/tmp/skylight-walk-on-reference.png` + +Prompt: + +Use case: style-transfer. Create one square product thumbnail for a building editor style picker. Image 1 is the actual skylight geometry reference captured on a roof; isolate only that skylight. Image 2 is the existing icon style reference: match its lavender clay material, soft studio lighting and clean isometric product rendering. Subject: A closed walk-on rooflight with a very low, nearly flush slim rim and thick glass pane. No raised curb. Preserve the geometry details and proportions shown in image 1, but use the camera presentation and lavender palette of image 2. Center one complete object filling 80% of the square. Glass should be subtly translucent lavender with readable frame edges. Truly transparent background. Remove all roof, building, UI, text, selection outlines, logos, scenery and watermark. No pedestal. + +## lantern + +Asset: `apps/editor/public/icons/skylight/lantern.webp` + +Reference capture: `/tmp/skylight-lantern-reference.png` + +Prompt: + +Use case: style-transfer. Create one square product thumbnail for a building editor style picker. Image 1 is the actual skylight geometry reference captured on a roof; isolate only that skylight. Image 2 is the existing icon style reference: match its lavender clay material, soft studio lighting and clean isometric product rendering. Subject: A pyramid roof lantern: four triangular glass faces meet at one pointed apex, with thin diagonal frame bars, on a rectangular raised curb. Preserve the geometry details and proportions shown in image 1, but use the camera presentation and lavender palette of image 2. Center one complete object filling 80% of the square. Glass should be subtly translucent lavender with readable frame edges. Truly transparent background. Remove all roof, building, UI, text, selection outlines, logos, scenery and watermark. No pedestal. + +## opening + +Asset: `apps/editor/public/icons/skylight/opening.webp` + +Reference capture: `/tmp/skylight-opening-reference.png` + +Prompt: + +Use case: style-transfer. Create one square product thumbnail for a building editor style picker. Image 1 is the actual skylight geometry reference captured on a roof; isolate only that skylight. Image 2 is the existing icon style reference: match its lavender clay material, soft studio lighting and clean isometric product rendering. Subject: An opening skylight with a single glazed rectangular sash tilted upward about 23 degrees on its top-edge hinge over the rectangular curb. Preserve the geometry details and proportions shown in image 1, but use the camera presentation and lavender palette of image 2. Center one complete object filling 80% of the square. Glass should be subtly translucent lavender with readable frame edges. Truly transparent background. Remove all roof, building, UI, text, selection outlines, logos, scenery and watermark. No pedestal. + +## sliding + +Asset: `apps/editor/public/icons/skylight/sliding.webp` + +Reference capture: `/tmp/skylight-sliding-reference.png` + +Prompt: + +Use case: style-transfer. Create one square product thumbnail for a building editor style picker. Image 1 is the actual skylight geometry reference captured on a roof; isolate only that skylight. Image 2 is the existing icon style reference: match its lavender clay material, soft studio lighting and clean isometric product rendering. Subject: A sliding skylight with two narrow overlapping glazed panes, one displaced sideways on parallel tracks, in a rectangular curb. Preserve the geometry details and proportions shown in image 1, but use the camera presentation and lavender palette of image 2. Center one complete object filling 80% of the square. Glass should be subtly translucent lavender with readable frame edges. Truly transparent background. Remove all roof, building, UI, text, selection outlines, logos, scenery and watermark. No pedestal. diff --git a/wiki/design/solar-panel-thumbnails.md b/wiki/design/solar-panel-thumbnails.md new file mode 100644 index 0000000000..3c35bb34a7 --- /dev/null +++ b/wiki/design/solar-panel-thumbnails.md @@ -0,0 +1,35 @@ +# Solar panel style thumbnails + +Generated with the built-in imagegen tool using roof-mounted editor captures on 2026-10-22. Final assets: `apps/editor/public/icons/solar-panel/{residential,residential-large,compact,frameless}.webp`. Each is transparent 256 × 256 WebP, quality 90, alpha quality 100. + +## residential + +Reference: `/tmp/solar-residential-reference.png` and the existing solar-panel icon for material/lighting. + +Exact prompt: + +Use case: stylized-concept. Architectural editor style icon. Reference 1 shows the actual single solar module geometry on a roof; reference 2 supplies only the soft lavender clay material, lighting and finish. Generate ONE isolated photovoltaic module: 1.00m wide by 1.65m long, a distinct 4cm perimeter frame, six columns by ten rows of clipped-corner photovoltaic cells. Preserve rectangular proportions and a subtle recessed cell grid. Include discreet short mounting feet and two rails underneath, flush low-profile mounting. Match the first reference geometry; do not copy the second reference's two-module layout or tall triangular stand. Isometric three-quarter product view, centered 80 percent canvas, lavender monochrome cells and frame, soft upper-left studio light, subtle ambient occlusion, transparent background. No roof, UI, text, labels, glow, extra modules or objects. + +## residential-large + +Reference: `/tmp/solar-residential-large-reference.png` and the existing solar-panel icon for material/lighting. + +Exact prompt: + +Use case: stylized-concept. Architectural editor style icon. Reference 1 shows the actual single solar module geometry on a roof; reference 2 supplies only the soft lavender clay material, lighting and finish. Generate ONE isolated photovoltaic module: 1.00m wide by 2.00m long, distinctly elongated, a 4cm perimeter frame, six columns by twelve rows of photovoltaic cells. Preserve rectangular proportions and a subtle recessed cell grid. Include discreet short mounting feet and two rails underneath, flush low-profile mounting. Match the first reference geometry; do not copy the second reference's two-module layout or tall triangular stand. Isometric three-quarter product view, centered 80 percent canvas, lavender monochrome cells and frame, soft upper-left studio light, subtle ambient occlusion, transparent background. No roof, UI, text, labels, glow, extra modules or objects. + +## compact + +Reference: `/tmp/solar-compact-reference.png` and the existing solar-panel icon for material/lighting. + +Exact prompt: + +Use case: stylized-concept. Architectural editor style icon. Reference 1 shows the actual single solar module geometry on a roof; reference 2 supplies only the soft lavender clay material, lighting and finish. Generate ONE isolated photovoltaic module: 0.83m wide by 1.00m long, almost square, a 3cm perimeter frame, five columns by six rows of photovoltaic cells. Preserve rectangular proportions and a subtle recessed cell grid. Include discreet short mounting feet and two rails underneath, flush low-profile mounting. Match the first reference geometry; do not copy the second reference's two-module layout or tall triangular stand. Isometric three-quarter product view, centered 80 percent canvas, lavender monochrome cells and frame, soft upper-left studio light, subtle ambient occlusion, transparent background. No roof, UI, text, labels, glow, extra modules or objects. + +## frameless + +Reference: `/tmp/solar-frameless-reference.png` and the existing solar-panel icon for material/lighting. + +Exact prompt: + +Use case: stylized-concept. Architectural editor style icon. Reference 1 shows the actual single solar module geometry on a roof; reference 2 supplies only the soft lavender clay material, lighting and finish. Generate ONE isolated photovoltaic module: 1.00m wide by 1.65m long, wafer-thin 5mm edge with almost no visible perimeter frame, cells extend nearly edge-to-edge, six columns by ten rows of photovoltaic cells. Preserve rectangular proportions and a subtle recessed cell grid. Include discreet short mounting feet and two rails underneath, flush low-profile mounting. Match the first reference geometry; do not copy the second reference's two-module layout or tall triangular stand. Isometric three-quarter product view, centered 80 percent canvas, lavender monochrome cells and frame, soft upper-left studio light, subtle ambient occlusion, transparent background. No roof, UI, text, labels, glow, extra modules or objects. diff --git a/wiki/design/turbine-vent-thumbnails.md b/wiki/design/turbine-vent-thumbnails.md new file mode 100644 index 0000000000..3cdea35269 --- /dev/null +++ b/wiki/design/turbine-vent-thumbnails.md @@ -0,0 +1,12 @@ +# Turbine vent style thumbnails + +Generated with the built-in image generation tool from clean screenshots of each implemented turbine style on a roof. The existing turbine icon supplied the lavender visual style. The first cylinder candidate was rejected for a rounded silhouette and regenerated with the explicit straight-sided geometry prompt below. Final assets are transparent 256×256 WebP, converted with Sharp at quality 90 and alpha quality 100. + +## Globe + +Use case: stylized-concept. Generate one square transparent architectural editor style thumbnail. Image 1 is the actual globe turbine vent placed on a roof, used as the geometry reference: isolate only the small white turbine; omit roof and editor UI. Image 2 is the established lavender icon style reference ONLY. Depict one upright turbine vent with a thin circular flange, short cylindrical neck, rounded globe head with broad smoothly curved twisted vanes tapering at top and bottom, a small domed top cap, thin retaining rings at vane ends, and small central spindle knob. Preserve the actual model proportions: diameter 0.32, total height 0.42, neck 0.09, flange diameter 0.42. No added screws, holes, brackets or roof pieces. Pale lavender clay render, lilac highlights and deeper purple recesses, soft upper-left studio light, ambient occlusion. Three-quarter isometric view, centered, full object occupying 80% of square frame with clear margins. Match the established icon family, readable at 64px. Genuine transparent background, no ground plane, no roof, no text, no UI, no watermark. + +## Cylinder (final) + +Use case: stylized-concept. Make a square transparent UI icon of ONLY the small white CYLINDER vent shown in the attached editor screenshot. Ignore the roof and interface. The head is a straight-sided vertical CYLINDER, not a sphere or globe: top radius, middle radius, bottom radius EXACTLY EQUAL. Its silhouette is two straight parallel lines. Flat wide round lid on top, tiny central button knob, 20 close-spaced gently twisted vertical metal vanes forming the cylindrical sidewall. Narrow rings at both ends. Below the head, short round neck and a thin flat round flange. Use actual screenshot geometry, with head height about head diameter, neck about one quarter head height, flange diameter 1.3 times head diameter. Pale lavender clay material, pale lilac highlights, deeper purple narrow recesses. Refined miniature 3D render, soft upper-left studio light and ambient occlusion. Upright centered isometric three-quarter view, fills 80% square, genuine transparent background. No spherical swelling, no tapered head, no dome, no extra holes or screws, no roof, no ground, no text or UI. + From af201bd191f2a272c7550a27654ce14d8e34f9e2 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 22 Oct 2026 16:48:20 +0530 Subject: [PATCH 2/6] Fix accessory preview layers and registry tool injection --- packages/editor/src/index.tsx | 6 +++- packages/nodes/src/box-vent/panel.tsx | 11 +++++-- packages/nodes/src/box-vent/preview.tsx | 5 +++- packages/nodes/src/box-vent/tool.test.tsx | 30 +++++++++++++++++-- packages/nodes/src/box-vent/tool.tsx | 8 +++-- packages/nodes/src/chimney/preview.tsx | 19 ++++++++---- packages/nodes/src/chimney/tool.tsx | 10 +++---- packages/nodes/src/cupola/preview.tsx | 4 ++- packages/nodes/src/cupola/tool.test.tsx | 17 +++++++++-- packages/nodes/src/cupola/tool.tsx | 8 +++-- packages/nodes/src/dormer/preview.tsx | 4 ++- packages/nodes/src/downspout/preview.tsx | 6 ++-- packages/nodes/src/eyebrow-vent/preview.tsx | 5 +++- packages/nodes/src/eyebrow-vent/tool.test.tsx | 17 +++++++++-- packages/nodes/src/eyebrow-vent/tool.tsx | 14 ++++----- packages/nodes/src/gutter/preview.tsx | 5 +++- packages/nodes/src/ridge-vent/preview.tsx | 4 ++- packages/nodes/src/ridge-vent/tool.test.tsx | 17 +++++++++-- packages/nodes/src/ridge-vent/tool.tsx | 14 ++++++--- .../src/skylight/__tests__/geometry.test.ts | 28 +++++++++++++++++ packages/nodes/src/skylight/preview.tsx | 15 +++++++--- packages/nodes/src/skylight/tool.tsx | 8 +++-- packages/nodes/src/solar-panel/preview.tsx | 3 ++ packages/nodes/src/solar-panel/tool.test.tsx | 17 +++++++++-- packages/nodes/src/solar-panel/tool.tsx | 16 +++++----- packages/nodes/src/turbine-vent/preview.tsx | 5 +++- packages/nodes/src/turbine-vent/tool.test.tsx | 17 +++++++++-- packages/nodes/src/turbine-vent/tool.tsx | 14 ++++----- 28 files changed, 248 insertions(+), 79 deletions(-) diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 67c6547209..c7b6812716 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -154,7 +154,11 @@ export { usePlacementCoordinator, } from './components/tools/item/use-placement-coordinator' export { MoveRegistryNodeTool } from './components/tools/registry/move-registry-node-tool' -export { useRegistryToolContext } from './components/tools/registry-tool-context' +export { + type RegistryToolContextValue, + RegistryToolProvider, + useRegistryToolContext, +} from './components/tools/registry-tool-context' export { CursorSphere } from './components/tools/shared/cursor-sphere' export { DRAFT_LABEL_Y_OFFSET, diff --git a/packages/nodes/src/box-vent/panel.tsx b/packages/nodes/src/box-vent/panel.tsx index 37dc95616d..903c2daa71 100644 --- a/packages/nodes/src/box-vent/panel.tsx +++ b/packages/nodes/src/box-vent/panel.tsx @@ -19,7 +19,7 @@ import { } from '@pascal-app/editor' import { getRoofOuterSurfaceFrameAtPoint, useViewer } from '@pascal-app/viewer' import { Copy, Move, Trash2 } from 'lucide-react' -import { useCallback } from 'react' +import { useCallback, useRef } from 'react' import { getDownSlopeYaw } from '../shared/roof-surface' import { boxVentDimensionPatch, boxVentDimensions } from './dimensions' import type { BoxVentNode } from './schema' @@ -57,6 +57,9 @@ export default function BoxVentPanel() { const node = effectiveNode ? { ...effectiveNode, ...boxVentDimensions(effectiveNode) } : undefined + const nodeRef = useRef(node) + nodeRef.current = node + // 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). @@ -72,12 +75,13 @@ export default function BoxVentPanel() { const previewProp = useCallback( (updates: Partial) => { if (!selectedId) return + const node = nodeRef.current useLiveNodeOverrides.getState().set(selectedId as AnyNodeId, { ...updates, ...(node ? boxVentDimensionPatch({ ...node, ...updates }, updates) : {}), }) }, - [selectedId, node], + [selectedId], ) // Slider release (commit): flush to the scene store as a single @@ -85,13 +89,14 @@ export default function BoxVentPanel() { const commitProp = useCallback( (updates: Partial) => { if (!selectedId) return + const node = nodeRef.current updateNode(selectedId as AnyNode['id'], { ...updates, ...(node ? boxVentDimensionPatch({ ...node, ...updates }, updates) : {}), }) useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId) }, - [selectedId, updateNode, node], + [selectedId, updateNode], ) // Discrete / one-shot updates (style switch, end-cap toggle) bypass diff --git a/packages/nodes/src/box-vent/preview.tsx b/packages/nodes/src/box-vent/preview.tsx index 2b6f26b9a9..fab3bf7ea1 100644 --- a/packages/nodes/src/box-vent/preview.tsx +++ b/packages/nodes/src/box-vent/preview.tsx @@ -1,5 +1,7 @@ 'use client' +import { EDITOR_LAYER } from '@pascal-app/editor' + import { useEffect, useMemo } from 'react' import * as THREE from 'three' import { INVALID_GHOST_COLOR } from '../shared/ghost-materials' @@ -62,13 +64,14 @@ const BoxVentPreview = ({ node, invalid }: { node: BoxVentNode; invalid?: boolea return ( { /* disabled — see component-level note */ }} /> - + ) + const renderer = await create( + false, + sceneApi: createSceneApi(useScene), + selectNode: () => {}, + unit: 'metric', + }} + > + + , + ) try { const event = { node: roof, @@ -48,6 +66,14 @@ test('box-vent placement matches its preview, honors grid mode and blocks unsupp stopPropagation: () => {}, } await act(async () => emitter.emit('roof:move', event)) + let renderables = 0 + renderer.scene.instance.traverse((object) => { + if (!(object instanceof THREE.Mesh || object instanceof THREE.LineSegments)) return + renderables++ + expect(object.layers.isEnabled(0)).toBe(false) + expect(object.layers.isEnabled(EDITOR_LAYER)).toBe(true) + }) + expect(renderables).toBeGreaterThan(0) const preview = usePlacementPreview.getState().node expect(preview?.type).toBe('box-vent') if (preview?.type !== 'box-vent') throw new Error('Missing box-vent preview') diff --git a/packages/nodes/src/box-vent/tool.tsx b/packages/nodes/src/box-vent/tool.tsx index 11fe72166f..930e8df4a0 100644 --- a/packages/nodes/src/box-vent/tool.tsx +++ b/packages/nodes/src/box-vent/tool.tsx @@ -1,12 +1,13 @@ 'use client' -import { type AnyNodeId, BoxVentNode, emitter, type RoofEvent, useScene } from '@pascal-app/core' +import { type AnyNodeId, BoxVentNode, emitter, type RoofEvent } from '@pascal-app/core' import { isGridSnapActive, isMagneticSnapActive, triggerSFX, useEditor, usePlacementPreview, + useRegistryToolContext, } from '@pascal-app/editor' import { getRoofOuterSurfaceFrameAtPoint, useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useState } from 'react' @@ -25,6 +26,7 @@ import { resolveBoxVentPlacement } from './placement' import BoxVentPreview from './preview' export default function BoxVentTool() { + const { sceneApi } = useRegistryToolContext() const activeBuildingId = useViewer((s) => s.selection.buildingId) const seed = useMemo( () => BoxVentNode.parse({ ...boxVentDefinition.defaults(), name: 'Box Vent' }), @@ -121,7 +123,7 @@ export default function BoxVentTool() { position: placement.position, rotation: placement.rotation, }) - useScene.getState().createNode(node, hit.segment.id as AnyNodeId) + sceneApi.upsert(node, hit.segment.id as AnyNodeId) useViewer.getState().setSelection({ selectedIds: [node.id] }) triggerSFX('sfx:item-place') clear() @@ -140,7 +142,7 @@ export default function BoxVentTool() { emitter.off('tool:cancel', clear) clear() } - }, [activeBuildingId, seed]) + }, [activeBuildingId, seed, sceneApi]) return ( <> diff --git a/packages/nodes/src/chimney/preview.tsx b/packages/nodes/src/chimney/preview.tsx index 6404048a6f..149e5a4406 100644 --- a/packages/nodes/src/chimney/preview.tsx +++ b/packages/nodes/src/chimney/preview.tsx @@ -5,6 +5,7 @@ import { type RoofSegmentNode, RoofSegmentNode as RoofSegmentSchema, } from '@pascal-app/core' +import { EDITOR_LAYER } from '@pascal-app/editor' import { useEffect, useMemo } from 'react' import * as THREE from 'three' import { INVALID_GHOST_COLOR } from '../shared/ghost-materials' @@ -63,11 +64,19 @@ const ChimneyPreview = ({ return ( - {}} /> - {geo.bands && {}} />} - {geo.cap && {}} />} - {geo.flues && {}} />} - {geo.cricket && {}} />} + {}} /> + {geo.bands && ( + {}} /> + )} + {geo.cap && ( + {}} /> + )} + {geo.flues && ( + {}} /> + )} + {geo.cricket && ( + {}} /> + )} ) } diff --git a/packages/nodes/src/chimney/tool.tsx b/packages/nodes/src/chimney/tool.tsx index 0633f09a86..1be50aa53b 100644 --- a/packages/nodes/src/chimney/tool.tsx +++ b/packages/nodes/src/chimney/tool.tsx @@ -8,7 +8,6 @@ import { type RoofNode, type RoofSegmentNode, sceneRegistry, - useScene, } from '@pascal-app/core' import { isGridSnapActive, @@ -16,6 +15,7 @@ import { triggerSFX, useEditor, usePlacementPreview, + useRegistryToolContext, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' @@ -51,6 +51,7 @@ type SegmentTransform = { } const ChimneyTool = () => { + const { sceneApi } = useRegistryToolContext() const activeBuildingId = useViewer((s) => s.selection.buildingId) const setSelection = useViewer((s) => s.setSelection) @@ -187,7 +188,6 @@ const ChimneyTool = () => { hit.localZ = placement.position[2] setValid(placement.valid || event.nativeEvent?.altKey === true) if (!placement.valid && event.nativeEvent?.altKey !== true) return - const state = useScene.getState() const chimney = ChimneyNode.parse({ ...chimneyDefinition.defaults(), @@ -196,8 +196,8 @@ const ChimneyTool = () => { 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) + sceneApi.upsert(chimney, hit.segment.id as AnyNodeId) + sceneApi.markDirty(hit.segment.id as AnyNodeId) setSelection({ selectedIds: [chimney.id] }) triggerSFX('sfx:item-place') const placementPreview = usePlacementPreview.getState() @@ -228,7 +228,7 @@ const ChimneyTool = () => { if (placementPreview.node?.id === previewNode.id) placementPreview.clear() clearRoofSurfacePlacementGuides() } - }, [activeBuildingId, setSelection, previewNode]) + }, [activeBuildingId, setSelection, previewNode, sceneApi]) return ( <> diff --git a/packages/nodes/src/cupola/preview.tsx b/packages/nodes/src/cupola/preview.tsx index 3a51c6cdfe..982899a892 100644 --- a/packages/nodes/src/cupola/preview.tsx +++ b/packages/nodes/src/cupola/preview.tsx @@ -1,6 +1,7 @@ 'use client' import { type AnyNodeId, type RoofSegmentNode, useScene } from '@pascal-app/core' +import { EDITOR_LAYER } from '@pascal-app/editor' import { useEffect, useMemo } from 'react' import * as THREE from 'three' @@ -61,13 +62,14 @@ const CupolaPreview = ({ node, invalid }: { node: CupolaNode; invalid?: boolean return ( { /* disabled — see component-level note */ }} /> - + ) + const renderer = await create( + false, + sceneApi: createSceneApi(useScene), + selectNode: () => {}, + unit: 'metric', + }} + > + + , + ) try { const event = { node: roof, diff --git a/packages/nodes/src/cupola/tool.tsx b/packages/nodes/src/cupola/tool.tsx index 928bef0e4d..75c12830cc 100644 --- a/packages/nodes/src/cupola/tool.tsx +++ b/packages/nodes/src/cupola/tool.tsx @@ -1,12 +1,13 @@ 'use client' -import { type AnyNodeId, CupolaNode, emitter, type RoofEvent, useScene } from '@pascal-app/core' +import { type AnyNodeId, CupolaNode, emitter, type RoofEvent } from '@pascal-app/core' import { isGridSnapActive, isMagneticSnapActive, triggerSFX, useEditor, usePlacementPreview, + useRegistryToolContext, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useState } from 'react' @@ -24,6 +25,7 @@ import { resolveCupolaPlacement } from './placement' import CupolaPreview from './preview' export default function CupolaTool() { + const { sceneApi } = useRegistryToolContext() const activeBuildingId = useViewer((s) => s.selection.buildingId) const seed = useMemo( () => CupolaNode.parse({ ...cupolaDefinition.defaults(), name: 'Cupola' }), @@ -115,7 +117,7 @@ export default function CupolaTool() { position: placement.position, rotation: placement.rotation, }) - useScene.getState().createNode(node, hit.segment.id as AnyNodeId) + sceneApi.upsert(node, hit.segment.id as AnyNodeId) useViewer.getState().setSelection({ selectedIds: [node.id] }) triggerSFX('sfx:item-place') clear() @@ -134,7 +136,7 @@ export default function CupolaTool() { emitter.off('tool:cancel', clear) clear() } - }, [activeBuildingId, seed]) + }, [activeBuildingId, seed, sceneApi]) return ( <> diff --git a/packages/nodes/src/dormer/preview.tsx b/packages/nodes/src/dormer/preview.tsx index e54490c443..a370d2026f 100644 --- a/packages/nodes/src/dormer/preview.tsx +++ b/packages/nodes/src/dormer/preview.tsx @@ -1,5 +1,7 @@ 'use client' +import { EDITOR_LAYER } from '@pascal-app/editor' + import { useEffect, useMemo } from 'react' import * as THREE from 'three' import { INVALID_GHOST_COLOR } from '../shared/ghost-materials' @@ -42,7 +44,7 @@ const DormerPreview = ({ node, invalid }: { node: DormerNode; invalid?: boolean useEffect(() => () => geo.dispose(), [geo]) - return {}} /> + return {}} /> } export default DormerPreview diff --git a/packages/nodes/src/downspout/preview.tsx b/packages/nodes/src/downspout/preview.tsx index 28efe27c8f..94a0f420ac 100644 --- a/packages/nodes/src/downspout/preview.tsx +++ b/packages/nodes/src/downspout/preview.tsx @@ -1,5 +1,7 @@ 'use client' +import { EDITOR_LAYER } from '@pascal-app/editor' + import { useEffect, useMemo } from 'react' import * as THREE from 'three' import { INVALID_GHOST_COLOR } from '../shared/ghost-materials' @@ -70,8 +72,8 @@ const DownspoutPreview = ({ return ( <> - {}} /> - + {}} /> + diff --git a/packages/nodes/src/eyebrow-vent/preview.tsx b/packages/nodes/src/eyebrow-vent/preview.tsx index 0c97026ede..0868790dd9 100644 --- a/packages/nodes/src/eyebrow-vent/preview.tsx +++ b/packages/nodes/src/eyebrow-vent/preview.tsx @@ -1,5 +1,7 @@ 'use client' +import { EDITOR_LAYER } from '@pascal-app/editor' + import { useEffect, useMemo } from 'react' import * as THREE from 'three' import { INVALID_GHOST_COLOR } from '../shared/ghost-materials' @@ -44,13 +46,14 @@ const EyebrowVentPreview = ({ node, invalid }: { node: EyebrowVentNode; invalid? return ( { /* disabled — see component-level note */ }} /> - + ) + const renderer = await create( + false, + sceneApi: createSceneApi(useScene), + selectNode: () => {}, + unit: 'metric', + }} + > + + , + ) try { const event = { node: roof, diff --git a/packages/nodes/src/eyebrow-vent/tool.tsx b/packages/nodes/src/eyebrow-vent/tool.tsx index 125d32ebb2..497a0dc858 100644 --- a/packages/nodes/src/eyebrow-vent/tool.tsx +++ b/packages/nodes/src/eyebrow-vent/tool.tsx @@ -1,18 +1,13 @@ 'use client' -import { - type AnyNodeId, - EyebrowVentNode, - emitter, - type RoofEvent, - useScene, -} from '@pascal-app/core' +import { type AnyNodeId, EyebrowVentNode, emitter, type RoofEvent } from '@pascal-app/core' import { isGridSnapActive, isMagneticSnapActive, triggerSFX, useEditor, usePlacementPreview, + useRegistryToolContext, } from '@pascal-app/editor' import { getRoofOuterSurfaceFrameAtPoint, useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useState } from 'react' @@ -31,6 +26,7 @@ import { resolveEyebrowVentPlacement } from './placement' import EyebrowVentPreview from './preview' export default function EyebrowVentTool() { + const { sceneApi } = useRegistryToolContext() const activeBuildingId = useViewer((s) => s.selection.buildingId) const seed = useMemo( () => EyebrowVentNode.parse({ ...eyebrowVentDefinition.defaults(), name: 'Eyebrow Vent' }), @@ -127,7 +123,7 @@ export default function EyebrowVentTool() { position: placement.position, rotation: placement.rotation, }) - useScene.getState().createNode(node, hit.segment.id as AnyNodeId) + sceneApi.upsert(node, hit.segment.id as AnyNodeId) useViewer.getState().setSelection({ selectedIds: [node.id] }) triggerSFX('sfx:item-place') clear() @@ -146,7 +142,7 @@ export default function EyebrowVentTool() { emitter.off('tool:cancel', clear) clear() } - }, [activeBuildingId, seed]) + }, [activeBuildingId, seed, sceneApi]) return ( <> diff --git a/packages/nodes/src/gutter/preview.tsx b/packages/nodes/src/gutter/preview.tsx index 5c03e204b3..aebb9e7620 100644 --- a/packages/nodes/src/gutter/preview.tsx +++ b/packages/nodes/src/gutter/preview.tsx @@ -1,5 +1,7 @@ 'use client' +import { EDITOR_LAYER } from '@pascal-app/editor' + import { useEffect, useMemo } from 'react' import * as THREE from 'three' import { INVALID_GHOST_COLOR } from '../shared/ghost-materials' @@ -68,13 +70,14 @@ const GutterPreview = ({ node, invalid }: { node: GutterNode; invalid?: boolean return ( <> {}} /> - + diff --git a/packages/nodes/src/ridge-vent/preview.tsx b/packages/nodes/src/ridge-vent/preview.tsx index b1299e4c16..5e5c62fc9e 100644 --- a/packages/nodes/src/ridge-vent/preview.tsx +++ b/packages/nodes/src/ridge-vent/preview.tsx @@ -1,6 +1,7 @@ 'use client' import type { RoofSegmentNode } from '@pascal-app/core' +import { EDITOR_LAYER } from '@pascal-app/editor' import { useEffect, useMemo } from 'react' import * as THREE from 'three' import { INVALID_GHOST_COLOR } from '../shared/ghost-materials' @@ -57,13 +58,14 @@ const RidgeVentPreview = ({ return ( { /* see box-vent preview note */ }} /> - + ) + const renderer = await create( + false, + sceneApi: createSceneApi(useScene), + selectNode: () => {}, + unit: 'metric', + }} + > + + , + ) try { const event = { node: roof, diff --git a/packages/nodes/src/ridge-vent/tool.tsx b/packages/nodes/src/ridge-vent/tool.tsx index c38df119fc..0f8e900728 100644 --- a/packages/nodes/src/ridge-vent/tool.tsx +++ b/packages/nodes/src/ridge-vent/tool.tsx @@ -6,9 +6,14 @@ import { RidgeVentNode, type RoofEvent, type RoofSegmentNode, - useScene, } from '@pascal-app/core' -import { isGridSnapActive, triggerSFX, useEditor, usePlacementPreview } from '@pascal-app/editor' +import { + isGridSnapActive, + triggerSFX, + useEditor, + usePlacementPreview, + useRegistryToolContext, +} from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useState } from 'react' import { roofSegmentLocalToBuildingLocal } from '../shared/relative-roof-drag' @@ -23,6 +28,7 @@ import { resolveRidgeVentPlacement } from './placement' import RidgeVentPreview from './preview' export default function RidgeVentTool() { + const { sceneApi } = useRegistryToolContext() const activeBuildingId = useViewer((s) => s.selection.buildingId) const seed = useMemo( () => RidgeVentNode.parse({ ...ridgeVentDefinition.defaults(), name: 'Ridge Vent' }), @@ -104,7 +110,7 @@ export default function RidgeVentTool() { position: placement.position, rotation: placement.rotation, }) - useScene.getState().createNode(node, hit.segment.id as AnyNodeId) + sceneApi.upsert(node, hit.segment.id as AnyNodeId) useViewer.getState().setSelection({ selectedIds: [node.id] }) triggerSFX('sfx:item-place') clear() @@ -123,7 +129,7 @@ export default function RidgeVentTool() { emitter.off('tool:cancel', clear) clear() } - }, [activeBuildingId, seed]) + }, [activeBuildingId, seed, sceneApi]) return ( <> diff --git a/packages/nodes/src/skylight/__tests__/geometry.test.ts b/packages/nodes/src/skylight/__tests__/geometry.test.ts index 498656dbb8..f4ec714707 100644 --- a/packages/nodes/src/skylight/__tests__/geometry.test.ts +++ b/packages/nodes/src/skylight/__tests__/geometry.test.ts @@ -62,3 +62,31 @@ test('frame leaves the glass opening clear and covers the cut edge with flashing geometry.dispose() material.dispose() }) + +test('every skylight preview style stays off the scene camera layer after style changes', async () => { + const { create } = await import('@react-three/test-renderer') + const { createElement } = await import('react') + const { EDITOR_LAYER } = await import('@pascal-app/editor') + const { skylightDefinition } = await import('../index') + const { default: Preview } = await skylightDefinition.preview!() + const renderer = await create(createElement(Preview, { node: SkylightNode.parse({}) })) + try { + for (const skylightType of ['flat', 'opening', 'sliding', 'lantern', 'walk-on'] as const) { + await renderer.update( + createElement(Preview, { + node: SkylightNode.parse({ skylightType, operationState: 0.5, motorHousing: true }), + }), + ) + let meshes = 0 + renderer.scene.instance.traverse((object) => { + if (!(object instanceof Mesh)) return + meshes++ + expect(object.layers.isEnabled(0)).toBe(false) + expect(object.layers.isEnabled(EDITOR_LAYER)).toBe(true) + }) + expect(meshes).toBeGreaterThan(0) + } + } finally { + await renderer.unmount() + } +}) diff --git a/packages/nodes/src/skylight/preview.tsx b/packages/nodes/src/skylight/preview.tsx index b89fbd633b..58c33bc321 100644 --- a/packages/nodes/src/skylight/preview.tsx +++ b/packages/nodes/src/skylight/preview.tsx @@ -1,6 +1,8 @@ 'use client' -import { useEffect, useMemo } from 'react' +import { EDITOR_LAYER } from '@pascal-app/editor' + +import { useEffect, useLayoutEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { INVALID_GHOST_COLOR } from '../shared/ghost-materials' import { buildFrameGeometry } from './frame-csg' @@ -28,6 +30,11 @@ const invalidGhostMaterial = new THREE.MeshStandardMaterial({ }) const SkylightPreview = ({ node, invalid }: { node: SkylightNode; invalid?: boolean }) => { + const root = useRef(null) + // Glass subcomponents are shared with the scene renderer, so tag their descendants here. + useLayoutEffect(() => { + root.current?.traverse((object) => object.layers.set(EDITOR_LAYER)) + }) const material = invalid ? invalidGhostMaterial : ghostMaterial const frame = useMemo( @@ -56,8 +63,8 @@ const SkylightPreview = ({ node, invalid }: { node: SkylightNode; invalid?: bool if (!frame) return null return ( - - {}} /> + + {}} /> {node.skylightType === 'lantern' ? ( ) : ( - {}} /> + {}} /> )} ) diff --git a/packages/nodes/src/skylight/tool.tsx b/packages/nodes/src/skylight/tool.tsx index 5a3ec080fe..d491cd53a0 100644 --- a/packages/nodes/src/skylight/tool.tsx +++ b/packages/nodes/src/skylight/tool.tsx @@ -1,12 +1,13 @@ 'use client' -import { type AnyNodeId, emitter, type RoofEvent, SkylightNode, useScene } from '@pascal-app/core' +import { type AnyNodeId, emitter, type RoofEvent, SkylightNode } from '@pascal-app/core' import { isGridSnapActive, isMagneticSnapActive, triggerSFX, useEditor, usePlacementPreview, + useRegistryToolContext, } from '@pascal-app/editor' import { getRoofOuterSurfaceFrameAtPoint, useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useState } from 'react' @@ -25,6 +26,7 @@ import { resolveSkylightPlacement } from './placement' import SkylightPreview from './preview' export default function SkylightTool() { + const { sceneApi } = useRegistryToolContext() const activeBuildingId = useViewer((s) => s.selection.buildingId) const seed = useMemo( () => SkylightNode.parse({ ...skylightDefinition.defaults(), name: 'Skylight' }), @@ -118,7 +120,7 @@ export default function SkylightTool() { position: placement.position, rotation: placement.rotation, }) - useScene.getState().createNode(node, hit.segment.id as AnyNodeId) + sceneApi.upsert(node, hit.segment.id as AnyNodeId) useViewer.getState().setSelection({ selectedIds: [node.id] }) triggerSFX('sfx:item-place') clear() @@ -137,7 +139,7 @@ export default function SkylightTool() { emitter.off('tool:cancel', clear) clear() } - }, [activeBuildingId, seed]) + }, [activeBuildingId, seed, sceneApi]) return ( <> diff --git a/packages/nodes/src/solar-panel/preview.tsx b/packages/nodes/src/solar-panel/preview.tsx index 18a67680b7..a86cf4bcca 100644 --- a/packages/nodes/src/solar-panel/preview.tsx +++ b/packages/nodes/src/solar-panel/preview.tsx @@ -1,5 +1,7 @@ 'use client' +import { EDITOR_LAYER } from '@pascal-app/editor' + import { useEffect, useMemo } from 'react' import * as THREE from 'three' import { INVALID_GHOST_COLOR } from '../shared/ghost-materials' @@ -55,6 +57,7 @@ const SolarPanelPreview = ({ node, invalid }: { node: SolarPanelNode; invalid?: { diff --git a/packages/nodes/src/solar-panel/tool.test.tsx b/packages/nodes/src/solar-panel/tool.test.tsx index 35e5837db3..13239682b9 100644 --- a/packages/nodes/src/solar-panel/tool.test.tsx +++ b/packages/nodes/src/solar-panel/tool.test.tsx @@ -1,6 +1,7 @@ import { expect, spyOn, test } from 'bun:test' import { BuildingNode, + createSceneApi, emitter, LevelNode, RoofNode, @@ -8,7 +9,7 @@ import { sceneRegistry, useScene, } from '@pascal-app/core' -import { usePlacementPreview } from '@pascal-app/editor' +import { RegistryToolProvider, usePlacementPreview } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { act, create } from '@react-three/test-renderer' import * as THREE from 'three' @@ -39,7 +40,19 @@ test('roof hover previews the same full array that clicking creates', async () = useViewer.getState().setSelection({ buildingId: building.id, levelId: level.id }) sceneRegistry.nodes.set(segment.id, new THREE.Group()) const { default: Tool } = await solarPanelDefinition.tool!() - const renderer = await create() + const renderer = await create( + false, + sceneApi: createSceneApi(useScene), + selectNode: () => {}, + unit: 'metric', + }} + > + + , + ) try { const event = { node: roof, diff --git a/packages/nodes/src/solar-panel/tool.tsx b/packages/nodes/src/solar-panel/tool.tsx index e75a1a2b43..e9a6817f8e 100644 --- a/packages/nodes/src/solar-panel/tool.tsx +++ b/packages/nodes/src/solar-panel/tool.tsx @@ -1,12 +1,13 @@ 'use client' -import { type AnyNodeId, emitter, type RoofEvent, SolarPanelNode, useScene } from '@pascal-app/core' +import { type AnyNodeId, emitter, type RoofEvent, SolarPanelNode } from '@pascal-app/core' import { isGridSnapActive, isMagneticSnapActive, triggerSFX, useEditor, usePlacementPreview, + useRegistryToolContext, } from '@pascal-app/editor' import { getRoofOuterSurfaceFrameAtPoint, useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useState } from 'react' @@ -26,6 +27,7 @@ import { resolveSolarPanelPlacement } from './placement' import SolarPanelPreview from './preview' export default function SolarPanelTool() { + const { sceneApi } = useRegistryToolContext() const activeBuildingId = useViewer((s) => s.selection.buildingId) const seed = useMemo( () => SolarPanelNode.parse({ ...solarPanelDefinition.defaults(), name: 'Solar Panel' }), @@ -52,7 +54,7 @@ export default function SolarPanelTool() { if (!hit) return null const force = event.nativeEvent?.altKey === true let placement = resolveSolarPanelPlacement(hit.segment, seed, hit.localX, hit.localZ, { - obstacles: solarPanelObstacles(hit.segment, seed, Object.values(useScene.getState().nodes)), + obstacles: solarPanelObstacles(hit.segment, seed, Object.values(sceneApi.nodes())), gridStep: !force && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0, }) if (!force && isMagneticSnapActive()) { @@ -67,11 +69,7 @@ export default function SolarPanelTool() { node: { ...seed, rotation: placement.rotation }, }) placement = resolveSolarPanelPlacement(hit.segment, seed, target.localX, target.localZ, { - obstacles: solarPanelObstacles( - hit.segment, - seed, - Object.values(useScene.getState().nodes), - ), + obstacles: solarPanelObstacles(hit.segment, seed, Object.values(sceneApi.nodes())), }) } return { hit, placement, force } @@ -126,7 +124,7 @@ export default function SolarPanelTool() { position: placement.position, rotation: placement.rotation, }) - useScene.getState().createNode(node, hit.segment.id as AnyNodeId) + sceneApi.upsert(node, hit.segment.id as AnyNodeId) useViewer.getState().setSelection({ selectedIds: [node.id] }) triggerSFX('sfx:item-place') clear() @@ -145,7 +143,7 @@ export default function SolarPanelTool() { emitter.off('tool:cancel', clear) clear() } - }, [activeBuildingId, seed]) + }, [activeBuildingId, seed, sceneApi]) return ( <> diff --git a/packages/nodes/src/turbine-vent/preview.tsx b/packages/nodes/src/turbine-vent/preview.tsx index f85cac532e..f810896d90 100644 --- a/packages/nodes/src/turbine-vent/preview.tsx +++ b/packages/nodes/src/turbine-vent/preview.tsx @@ -1,5 +1,7 @@ 'use client' +import { EDITOR_LAYER } from '@pascal-app/editor' + import { useEffect, useMemo } from 'react' import * as THREE from 'three' import { INVALID_GHOST_COLOR } from '../shared/ghost-materials' @@ -45,13 +47,14 @@ const TurbineVentPreview = ({ node, invalid }: { node: TurbineVentNode; invalid? return ( { /* disabled — see component-level note */ }} /> - + ) + const renderer = await create( + false, + sceneApi: createSceneApi(useScene), + selectNode: () => {}, + unit: 'metric', + }} + > + + , + ) try { const event = { node: roof, diff --git a/packages/nodes/src/turbine-vent/tool.tsx b/packages/nodes/src/turbine-vent/tool.tsx index a9336cde6d..c6b0bd6182 100644 --- a/packages/nodes/src/turbine-vent/tool.tsx +++ b/packages/nodes/src/turbine-vent/tool.tsx @@ -1,18 +1,13 @@ 'use client' -import { - type AnyNodeId, - emitter, - type RoofEvent, - TurbineVentNode, - useScene, -} from '@pascal-app/core' +import { type AnyNodeId, emitter, type RoofEvent, TurbineVentNode } from '@pascal-app/core' import { isGridSnapActive, isMagneticSnapActive, triggerSFX, useEditor, usePlacementPreview, + useRegistryToolContext, } from '@pascal-app/editor' import { getRoofOuterSurfaceFrameAtPoint, useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useState } from 'react' @@ -31,6 +26,7 @@ import { resolveTurbineVentPlacement } from './placement' import TurbineVentPreview from './preview' export default function TurbineVentTool() { + const { sceneApi } = useRegistryToolContext() const activeBuildingId = useViewer((s) => s.selection.buildingId) const seed = useMemo( () => TurbineVentNode.parse({ ...turbineVentDefinition.defaults(), name: 'Turbine Vent' }), @@ -127,7 +123,7 @@ export default function TurbineVentTool() { position: placement.position, rotation: placement.rotation, }) - useScene.getState().createNode(node, hit.segment.id as AnyNodeId) + sceneApi.upsert(node, hit.segment.id as AnyNodeId) useViewer.getState().setSelection({ selectedIds: [node.id] }) triggerSFX('sfx:item-place') clear() @@ -146,7 +142,7 @@ export default function TurbineVentTool() { emitter.off('tool:cancel', clear) clear() } - }, [activeBuildingId, seed]) + }, [activeBuildingId, seed, sceneApi]) return ( <> From 84626dbeeaa2b53b3983357a7ac17bed396fab46 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 22 Oct 2026 17:00:33 +0530 Subject: [PATCH 3/6] Document local verification with compact screenshots and legacy fixture --- wiki/design/pr-1125-verification/README.md | 28 +++ .../design/pr-1125-verification/box-vent.webp | Bin 0 -> 33102 bytes .../pr-1125-verification/build-search.webp | Bin 0 -> 31226 bytes wiki/design/pr-1125-verification/canopy.webp | Bin 0 -> 37166 bytes wiki/design/pr-1125-verification/hvac.webp | Bin 0 -> 36858 bytes .../pr-1125-verification/legacy-scene.json | 232 ++++++++++++++++++ wiki/design/pr-1125-verification/legacy.webp | Bin 0 -> 33252 bytes 7 files changed, 260 insertions(+) create mode 100644 wiki/design/pr-1125-verification/README.md create mode 100644 wiki/design/pr-1125-verification/box-vent.webp create mode 100644 wiki/design/pr-1125-verification/build-search.webp create mode 100644 wiki/design/pr-1125-verification/canopy.webp create mode 100644 wiki/design/pr-1125-verification/hvac.webp create mode 100644 wiki/design/pr-1125-verification/legacy-scene.json create mode 100644 wiki/design/pr-1125-verification/legacy.webp diff --git a/wiki/design/pr-1125-verification/README.md b/wiki/design/pr-1125-verification/README.md new file mode 100644 index 0000000000..2810e0333a --- /dev/null +++ b/wiki/design/pr-1125-verification/README.md @@ -0,0 +1,28 @@ +# PR 1125 local verification + +Verified on 2026-10-22 with `bun dev --port 3017`, from `apps/editor` at commit `af201bd19`. + +- Build search: entering “air conditioner” returns HVAC Unit. +- HVAC: placed a condenser and inspected its service face and separate suction/liquid diameter controls. +- Canopy: switched a freestanding canopy from Butterfly to Gable; checked the rendered roof and matching style cards. +- Legacy load: imported [legacy-scene.json](legacy-scene.json) through Settings → Load build into an empty project, then reloaded the browser. The furnace, refrigerant run, and roof-hosted box vent remained present. The furnace inspector recognized one connected refrigerant run and supplied default connection fields. The box vent retained Cap style and zero flashing extension, and could switch to Dome. + +The legacy fixture is synthetic: it represents the pre-change schema by omitting the new HVAC face/diameter fields and box-vent flashing field, while retaining the existing lineset diameter data. It is not a historical customer scene. These manual checks complement the automated connection, placement, and preview-layer tests; they do not claim exhaustive manual coverage of every accessory or interaction. + +## Build search + +![Air conditioner search](build-search.webp) + +## HVAC placement and controls + +![Condenser controls](hvac.webp) + +## Roof accessory styles + +![Box vent loaded from legacy fixture](box-vent.webp) + +![Gable canopy and style cards](canopy.webp) + +## Legacy HVAC load + +![Legacy furnace and connected refrigerant run](legacy.webp) diff --git a/wiki/design/pr-1125-verification/box-vent.webp b/wiki/design/pr-1125-verification/box-vent.webp new file mode 100644 index 0000000000000000000000000000000000000000..fcc66e926239b21c3e8abd2b9e49912430027732 GIT binary patch literal 33102 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 literal 0 HcmV?d00001 diff --git a/wiki/design/pr-1125-verification/canopy.webp b/wiki/design/pr-1125-verification/canopy.webp new file mode 100644 index 0000000000000000000000000000000000000000..ca0f0ed130dc8afb447a5b5f979fb8ef8244ca7a GIT binary patch literal 37166 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 Bu`B=p literal 0 HcmV?d00001 diff --git a/wiki/design/pr-1125-verification/hvac.webp b/wiki/design/pr-1125-verification/hvac.webp new file mode 100644 index 0000000000000000000000000000000000000000..57fd47daf51f74ea7620ead3555ec321621bd094 GIT binary patch literal 36858 zcmZVlV~{3YxGjnnx@_CFZQHhuF59-vF59+k+qSy+7H)rQ?R{_DbN*yxL`FPw=EE^Y z&Qz8X7k`Wg0@4r@R#aEyBs~3hk2nXC3rr0JHV?w@$(<=pT0~Yz%=~^{j{s$5|I@+6 z`Rlj=ZNCL1BQ^`5ZM~Iqqzg(xfvF2e2u;Kl`+gchHnTg;}`kU0A4@lZu7eYj=ppJslKm1%Z~Pn z`jGlsf3UtyUiywP9{o=V&VCxcAAb@)ggy=4_1bg{0Ck@N=e|LFjvo}Cb@%=~eb@X3 zJx^a2Z+E*4*Ly$T>K{8lpr83C1nU@2dzU}g0wdoqKXyMqFBYGB9|n1Tse2(mG62te zu+Jj^!Y9I=|M`zKe^KAq&&GGz*Vt`%mq59|qkqAV-Xq565`g2Y?w;V{>*?p)fB(%9 zQ1$b)i}Q-|(>~{5@Jo;y3ld84(oBR1m>N&E+?0^eSPwe59Tb<4Or#x+A&KEW22#WC{HoYPy@;kYA;n z+8!{3b`kkC63KT+_Ngs=yYhmb8W^vhoO5$yYG1TEgr0|0bM#(!{p7&gb9nP~OaIR` zWbJ!74wp+Y9*%RCWp5xvmB#dm&H3N_c!0>+d!vX5C~0IfU$|p=e=}$%0D^Af5RnU7 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 literal 0 HcmV?d00001 diff --git a/wiki/design/pr-1125-verification/legacy-scene.json b/wiki/design/pr-1125-verification/legacy-scene.json new file mode 100644 index 0000000000..b9f2752857 --- /dev/null +++ b/wiki/design/pr-1125-verification/legacy-scene.json @@ -0,0 +1,232 @@ +{ + "nodes": { + "site_mlo10yprppfq84s0": { + "object": "node", + "id": "site_mlo10yprppfq84s0", + "type": "site", + "name": "Legacy compatibility fixture", + "parentId": null, + "visible": true, + "metadata": {}, + "polygon": { + "type": "polygon", + "points": [ + [ + -15, + -15 + ], + [ + 15, + -15 + ], + [ + 15, + 15 + ], + [ + -15, + 15 + ] + ] + }, + "children": [ + "building_fg4szvh7ijroahww" + ] + }, + "building_fg4szvh7ijroahww": { + "object": "node", + "id": "building_fg4szvh7ijroahww", + "type": "building", + "parentId": "site_mlo10yprppfq84s0", + "visible": true, + "metadata": {}, + "children": [ + "level_op0gl45ewj0o28ez" + ], + "position": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ] + }, + "level_op0gl45ewj0o28ez": { + "object": "node", + "id": "level_op0gl45ewj0o28ez", + "type": "level", + "parentId": "building_fg4szvh7ijroahww", + "visible": true, + "metadata": {}, + "children": [ + "roof_ozdeo42ynncziyni", + "hvac-equipment_32xpwvt213owkpw2", + "lineset_lffb9m5lq7iedt63" + ], + "level": 0, + "baseElevation": 0 + }, + "roof_ozdeo42ynncziyni": { + "object": "node", + "id": "roof_ozdeo42ynncziyni", + "type": "roof", + "parentId": "level_op0gl45ewj0o28ez", + "visible": true, + "metadata": {}, + "position": [ + -4, + 0, + 0 + ], + "rotation": 0, + "support": { + "kind": "level" + }, + "children": [ + "rseg_m0ysxl4fi5ou8qxt" + ] + }, + "rseg_m0ysxl4fi5ou8qxt": { + "object": "node", + "id": "rseg_m0ysxl4fi5ou8qxt", + "type": "roof-segment", + "parentId": "roof_ozdeo42ynncziyni", + "visible": true, + "metadata": {}, + "position": [ + 0, + 0, + 0 + ], + "rotation": 0, + "roofType": "flat", + "width": 4, + "depth": 4, + "trim": { + "left": 0, + "right": 0, + "front": 0, + "back": 0, + "frontLeft": 0, + "frontRight": 0, + "backLeft": 0, + "backRight": 0, + "frontLeftX": 0, + "frontLeftZ": 0, + "frontRightX": 0, + "frontRightZ": 0, + "backLeftX": 0, + "backLeftZ": 0, + "backRightX": 0, + "backRightZ": 0 + }, + "wallHeight": 0, + "pitch": 40, + "wallThickness": 0.1, + "deckThickness": 0.1, + "overhang": 0.3, + "shingleThickness": 0.05, + "managedByParent": false, + "wallShell": "auto", + "shedInsetEndPanels": false, + "gambrelLowerWidthRatio": 0.5, + "gambrelLowerHeightRatio": 0.6, + "mansardSteepWidthRatio": 0.15, + "mansardSteepHeightRatio": 0.7, + "dutchHipWidthRatio": 0.25, + "dutchHipHeightRatio": 0.5, + "dutchWaistLengthRatio": 0.98, + "dutchGabletRake": 0.48, + "dutchTopRakeThickness": 0.21, + "children": [ + "bvent_at2zwu47le8ts3vy" + ] + }, + "bvent_at2zwu47le8ts3vy": { + "object": "node", + "id": "bvent_at2zwu47le8ts3vy", + "type": "box-vent", + "name": "Legacy box vent", + "parentId": "rseg_m0ysxl4fi5ou8qxt", + "visible": true, + "metadata": {}, + "materialPreset": "preset-white", + "roofSegmentId": "rseg_m0ysxl4fi5ou8qxt", + "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": 1, + "baseInset": 0.06, + "baseHeight": 0.04, + "cornerBevel": 0.012, + "style": "cap" + }, + "hvac-equipment_32xpwvt213owkpw2": { + "object": "node", + "id": "hvac-equipment_32xpwvt213owkpw2", + "type": "hvac-equipment", + "name": "Legacy furnace", + "parentId": "level_op0gl45ewj0o28ez", + "visible": true, + "metadata": {}, + "position": [ + 1, + 0, + 0 + ], + "rotation": 0, + "equipmentType": "furnace", + "width": 0.56, + "depth": 0.71, + "height": 1.1, + "supplyShape": "round", + "returnShape": "round", + "supplyDiameter": 8, + "returnDiameter": 8, + "supplyWidth": 12, + "supplyHeight": 8, + "returnWidth": 14, + "returnHeight": 8 + }, + "lineset_lffb9m5lq7iedt63": { + "object": "node", + "id": "lineset_lffb9m5lq7iedt63", + "type": "lineset", + "name": "Legacy suction route", + "parentId": "level_op0gl45ewj0o28ez", + "visible": true, + "metadata": {}, + "path": [ + [ + 1.28, + 0.88, + 0 + ], + [ + 3, + 0.88, + 0 + ] + ], + "suctionDiameter": 0.875, + "liquidDiameter": 0.75, + "insulated": true + } + }, + "rootNodeIds": [ + "site_mlo10yprppfq84s0" + ] +} \ No newline at end of file diff --git a/wiki/design/pr-1125-verification/legacy.webp b/wiki/design/pr-1125-verification/legacy.webp new file mode 100644 index 0000000000000000000000000000000000000000..beebdc7c1ada3a3cc38a6cef0948b4ac1c296f6c GIT binary patch literal 33252 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

2OkoR#G8CRU)mn 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 m5fA%s7JlA_VC<*LmyPoAVK`ZZUrvLmSM=Z8E9{ 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 Date: Thu, 22 Oct 2026 17:11:10 +0530 Subject: [PATCH 4/6] Refresh PR screenshots without Next.js development badge --- .../design/pr-1125-verification/box-vent.webp | Bin 33102 -> 32758 bytes .../pr-1125-verification/build-search.webp | Bin 31226 -> 31000 bytes wiki/design/pr-1125-verification/canopy.webp | Bin 37166 -> 37898 bytes wiki/design/pr-1125-verification/hvac.webp | Bin 36858 -> 37330 bytes wiki/design/pr-1125-verification/legacy.webp | Bin 33252 -> 32566 bytes 5 files changed, 0 insertions(+), 0 deletions(-) diff --git a/wiki/design/pr-1125-verification/box-vent.webp b/wiki/design/pr-1125-verification/box-vent.webp index fcc66e926239b21c3e8abd2b9e49912430027732..f99d5731f37262e625b3a828472ffc7c50f3f75b 100644 GIT binary patch literal 32758 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 literal 33102 zcmZ6yV{|6l8ZH`}9jjv-9ox2TJL%ZAZQHi(j&0jczSC>%efGKI{;M&nY8KviKB`%Y z5~8A_{y;!#B7*X&^6Uhhf47n1L9&6VfWYTKc-^_vB}ocNOY)PkR_73*Eo=e3CN~Q1 zUM-=dS@-wtvk8PAy-)g>812%%9;AyBF%@YOW*8zV8`}wL)qrq-$T!YH=&G;%_sdu3 z^TsE6$u~Fm!zbdK@AJ;KPY&T2d+a^z zb?sh!zxuBI>^;m~#drXm_g?(n{|0b)7y1tUw8POG0ptVPzZ>r_E+OAuo_b$;cK{pT z(_f;#fq>dC#1Fl#S(hC--(vvB_vj(@Tj?|Y2mT)4(#M7G(~IXvBH&>g@p%VO*U8-; zO*0;9l5brxDY&MR65LQp3#*Jj2>&JP4+Iuh0t8Jc4fKO^hd4&3xzg@r`DZi?o+Vv3 z0D|(@fVtB`e2DS^imR`gH1+C{jbA!BCYbVRnY0`(j}b`)3V}4}_;f0?Hk1%S0%{*H z38f#1xYGX{R4@&QB$)oA_A@}um$@8DHZSo<_aD~sc%P<##5lA(?e4geu>O?Wg9|5| z@*>0B$B>H^k?1IvahSZ?PpiG)Mnwp$0)yFGaU>F=cJR|o4;_u!K+1rD`PX9CR#9ZzTz;RP;mv0`tL2 zK}%|V%tST9|K$~DPLpUoPnJ$K5&Q6w7j>fPV`ed?#D8qq18bI(_-9CpF>I2N*sd81 zPbC`Ys!^VmLW`p|Bc*QQZm$TUyw z2TDNgS^Hzw;No-jvnD`BOR5j`Diri3HQeE*nfKXD6=x0(W2{#a#(pWRNu+=lwhMr` zE3(j^f!rgUR8BYMslrAaf6=i6?=Ny9skGT4oLbp72#(4|gTuz4Ajdkx z1(?tw-a{Ao3QXeQBkaWx?a_h55B~dZ@;vVtb7ttZ;0c8t!Z=BvoqPmCUXO)1`bo0|SdyAi2ZNQqdAw;K&-!Y&!U`1tdvpYfZa(`PC5 z-(&E|`GWjmE@sDs6f0KlM`#}O191)1w9Vgg_d>^;LI!@Wt673bQsLV5iMD+2TD?mw zA1vj2vHMv+>v{FW(IA#n3G^zLsr$%RPOrTm)rpX|>n+$g;Vb)BOwm?qBj~M~71#zW zn5i$4oBwnXN>&Djf(A1bU5Vq?%!L*F2YC3GM>?)b{m|aiY%&2@ur8~^4av*S6GQB; z@Uc!j-ERmNX|lfpD`$lw&HPpMn_4*chr;h=|9CEH{`ewOjY(MtPhNvu+4E93MxNTy zht)niC|caW_dZ3Y1=6BI)o`vx^$mSr8pCwV zsM-j!BCmWLEeKfcOkP3dSr&73ebRIh+&I6V7=(y;VORUG2=umR56n9XAIX7t;yghJRqeoN&!;|2sB?as48Rm$1!Bk0C))HB<=?!;D7~ivF}qM9#nFd*CelihDP$WRSvO`+ZJgs97Pe`@;sn6BaAond+D16fp zNI+At#FnG_De>XmJiZ@ghvf_GWHeCRFl25?_}&%LG+Wlm%(3ZA7Airja+4+Qw-S8L zfPJ|4=%&-aF|HWu2s@*-Quf;?k3*V0?OS|FF$2Q=v|6pL@ zm9=F@!srB2S5O|Ab=8=-a*7RAksBgBj;@K@8kD6V4H?d1ro;Xn$BQp*6 z+#LWvt~Oq^mnu78`&)lK^iV65Onlv=kC3Tfv3-k*S#NaNhk_|5V%=M$NrA5* z)Cyo;^V6HpqT6uNJKu>QAHkd(&XSQw`MJ@|E%9@b?&QNq7k2jYeDPD&2S5LDfUZ`%LG|*{g~K1O8|iBx*JT#?jRu-9&(5Ajv1-W&fTDVE zbaMUhZ!b>S75yXHqyX#bHh3g_-_ir+-uQPb?XXO^?-m-HqTKSWD^N-Hj&`}Tr6g%j zkymgK_W))l=Y2nU09}pID@r7M_~fu3+sim~(!yHW!&;LY z4&uv}-Mq9*I*jCMwWl%? zj*jfaW&Uzf<^uP%IMvdVoOSoV@>yFvu;9MIM%M18coch3I>PNW0Gx9D%lFm1Sr3r5 z1TWh9nD9PkljgRLFZfB+!D6n^ai-sXnZP0GNv$WRUwD?Mvwg@;{}WXxYQC&#eF3J* zFPDoPDGziJ=5TM$$Qh!dCnob>X-_0DnZY8Wg7`$jcM z6Nq<__}*m4(lia0N{L2kgr3H)GpXfp`$s$%A%*>w?0?0bn1pQeVM+Pr(~}W#Pp+h8 z@z7+s2`!iyJ?M?zvu;$2#pXaq?64A&CE@tN-3zH0WU7oal0w7n-Cv7gY$Oae{;uzt z{KK0W*U7RMR~=7;s&*d;u>nvf3es4Y=t-okWhwAuahM+{<7&`P?#c(LIV;0b`bF0J zuc(Z36aJ6L49Y=1N#9$<$>_bb`7$!b!T9rV38I$J2aIh73gucA9i;*{-b_r%`4@TZ zuJKZW+}1w2&aHdXpv?RU~jAY+kvqg+m9EL7o z-2dZn;;N2$t@3^`?sP`lCgRg)o*BOofy3a`8XI+Pq{d6IUaRdbLbnIHAk$DCdN!|> zY8`kfN%F&w`Ys&;(^eNLR|Vo+a(ykL7JmJGVh`s|HtwBGE?QXg%U!<197#SM@R_kp zCLUt(Lb9y)=6}R?qeri!v&}mhsYmU4`EPIWqQ#)P@gs8_uvffDtHI@^HKv;5Hnx6| zNom})%WsakCY;QsjDMWn|C8(7MOV;KXbeC~11LkOj{MwGEbQ!b2lN?;4KP~Vh91eThkcGCX1TY*i zGd79co75R_ka2?xIYTKhCO2ZHX7O16%-R1qgumSN3khTlMG}n|fxN@IAa=TBKpXM= z@XTQo&0b-ty>PFCK8lpma3C}%v{@!L?{M6_KK0gz*17rm3b2U{WCs1{me^q>z_@oJ z#`r61LJ14{XayTp{Pxv!(5FsZyvNMyU$Xx@N&a=W75D7>*2U2U(OT>v!b69qIChU! zslAacdk{aZTFI9l_^K>jn{wsJLNzfptUf~79vu_#5uFEUQ{Di2=u@bjz+nbJykY0N zzG6a+7#h(sgny}QqT+wd^Ou^b8urr%VAbPa#v*lh$5%{RmI=u%w5aT*s}|t^4@k6^ z`PiMOEl?u0jf>2|w-9fC)cbRHj~|=zg-;*QSgB1&j#8yQ}Q)ZU+fp>5VmQ9AE4vMM|$P5FbthWBc1k7T> zfD!0i@o>QMf8!56eZb0{F&%}(I#qPu_Z zl>6W+x?fhm)1^b)u{tQWCK0t5v5c0RAIh|kdLlH|73KxawzMx1cq=FX!jg02BJ zIjpUvS&wp_)FM@`atyV>voIP`0u9l|cW*kk z+3Wi`b!&@=N}KRR9ieoKuI%SZ?3y}kAPuH9lmxU0KoTYA!HyfjGUf*+FwUmzP$OTh z(;Fw4Qq~E(w70<|d>Q~UKulurwZ2JT+#Y37Kq1IVf%)EEnSSR_CACpQ6<=5VNBJ=q z;A?Vq;_)F8cxiLGi$fB7FhW?*1UwjExP?dz;*{nJu*asLA^Qum-$ zC(ohpQFj=9PGAVmQ3)Z3N5H>QQRLwdod(I=LPbSAns}1O@)001p+9aLWyPV*gaxR;}5~_pumYk++W98I50jJ#n z`bZZoWl0YarNda~H|ZMRpLQ>lO@Drx4xlmtN|2wNWbtW7XpHukZwwa+tehd+5FTnJ z&M4oN7q%?BXNuHpC9_{`hP>W(ivCu~X^M=$5daxTFSg%uKVk0%l;=W#>0y=W>!qks zWqiHQ-O=)=adA_R1488N(6@p^0-z)~s(z$WdP2Fbzg)A)>&N<$;0yDePz z72j>of_#L0WuhJr=3eqfi$jc7&Q_PoCs%{9GkAe;LYS!W#HzNwXSmg5@~23&<^liY z#MdQ&!8{zL&xZ)`MQAzbmp-c8Q?(vqK_cDwv_(I@3^I^G$Yu|SwU#AZ@nXnDh05>W z;tkr`Dmw7z`l2#}d}3HFp^y*t0(b`kT8<({?y zyU%-*eSIhv9lR{m6`H3R2Adkgd8U>0_`9!l)r}| zG+I>s4w=;^?aN)Xeolp|oiW-l*;VZ!wBN4fWS>|Tau^o_PLbLA(dpS4SHF*9Xqt*W zRTc89_t^{gbWK0KxAbaBM(a|a{0+8DqbYVySu85_Veo?)`alq5&{Fqdk7o2UIzxN` z+7O|PcxoEQy9&FywM9&#ArmWYA;^>x@;1|f00IIPw9HlN)pngzzBjpT3DbzK<{GhyZQ93DJGf8sFFe#57=-u&gm_d~?ur*94z+w6g@opbG7jAbnQPszjI_zvUROJ?1AA?44z zSoRtc_Hc%TuIB5wV-g(C6PAXp!@oXZKELq6{%HFn;6-7SkAFYXZc{e!uM6rx7Og8! z*R4_sdLQHg(%cV9U-gvZa2MB(g2O7LM%ZoaPo$4_%a@4`<})5!5nHz2OQU40d_e2& zTpqg>5@~#v)Fj3{=7S8x_SMVpo?yHKU4FE;Ue-b3hbD%0dP>-ZT~_SfjoA3p^6k=H zAECy2%uY=kunNMTEIE=J<};dqV&$)oAi4y^+*TfgVi{1*S%nKRY3+5TrqaLCnE`)W zzmChSaIAEUtg6+m& zqH2Ggk`_o)XTcQK@Tf&|u*nGSm%s654JF8-$Ws+W;XXg~C@qflzFp#6Ftjnjl^hGO>VG8^A+##by17q z-M~7HL1t*{Vz-%il?b2ST8j1VW67k1I=(emgNQ&*vI_N}e z@>28HZ$(SZ;cUQ+N6WY|+1w(wa3eptaIqT*^Nd#+^wLVg7BZEn# zXSCW2*=?$XJe4|weV*DSy}j?cbd#JJGa~HreW5>!1DcJ4M^SL!(3Fxi)RbNV2sy5A*s2{ zkoa6h$f)YgxXeDQ&v4`5wj}AezRa_Rzg?D2yr}wAjYfO(uVyy#@6BJIz+c)6!Wx^% z`D7JvISW{r!b3LKc@^VaCXi8 z6+Gct6swqbd@0h^lBL<*bqLl5@kY4wdp>HdCS6Ba5s01gNUX#G1x zvgk!AbarYzQ2i_)Q9&Lx;*o=k=$EL`F4XIUuXak+LD>9^Rgpu_~&=&5A9{qbYIJY z>$W}I)}AgyDwW}2+3mWXC3FzUw{P|B;*h|na6nHK)9#f4gy)^~93@R$T8$M$+8P<7 zx~eTfN&3-yIHBF{?f6ALXE;xTxcfg8J!d^6f-Gv%x=_fGU&kARLJ}@ zBL;9Nn`pT*DC*!&8{E};W=KAnI}on;!mQ}3VOh-g8wbMh zJq#UntK?YrXy<$-m?;C4k3IfrMzgB|Em9Zibi2X+6&WHaXt)4h#dH$&H<*R^a`Drx z7h?wZ-rV1axP-k@oj<}GRzDxglQ|Nvsu!6!6j^>nmHNgaetpSSN_XMo!zVwM*R+L3 zi)C1oxujf?b=KrU;qZvx)_?Rdnx4Oe#@wY^6DVsnmgru+j7%Nxpc00vf>+$TP-4tR zT@1F?x^is%u!+Q?ul@;7h~9p#a~}VClwGc1;G=6F9PM@EkbNBPR^#K4oV^X&$czm< zr)iv90U}Cpt6WS+lv|W^XGHq(5lN8~L!#8NMFc7bQr5kXb~eG7@0Z1B{?E*u^rAdL zxo&t#v$^pF?S!yBCc2&KG>fPXo239|dU-u-V{%nfTaLhsc^L0_&S)E5A$VRXY1h@2 z1;U^z0TPV>6iZAUs49dtJdP~_O<`*kDnQ4f6LW5bq+-4pG;yXhac+?S=;taDMqRhIH!UxjhGN2=NcDT<)n9Zm_fECUieBp*jv4#}e zD>eE6(PTZ0D>st4tDfp5iMRDOD+w%bk9kWiW;H4vC&Y1I(`P|A_C9-ul2Bij&L~E@ zN)I^F7SmXFGP`dapD5=_cLF$`gLL)C0!0Zi{Uf4o(s$@?UZ_Isd!- zSxWpU@k{#!%^DCl%4|Gq*fVsNP_#yT^;2_8`z5M1M{E-}k0jEqb#T}Q?w&0KooJ?V z6`NHkcKO%sQss@AQ?n@d79mPi$38qWxd7mVskx5V8x8UUwD9JW{BX9&vvM28+9+oA zQg`Xlk{_R7 zerNVNLl3dhXIgyXajTL75xI`RnqxxE7o@X_F(@x~_T0@bL2GuBfIs1csUp%z5WAg_ zvce~c64j7fuqKOBmti-R9{!GHM18OTmP_uR-!DH-Q2TSj5I*#R-VTGI^y*}wC>-{c zAu(Zc#jiyfm8^?Pxf}Js+^ypsW@f#64x+wR(M9xge({X{5YeB#-1knc$=CM|5IgL;L~Y+|7gVlY)WcEDsW{(ZJTb0nXVn@O-_3Sn+oVJ*1O%V zW;YxXOb}h7-$<$r!3yMRA0XNAm4NJ7WXlC9KLd%Ae!HfDL$yhDy3z*VEo|f9&}*bx zKNt582r9KLWnS^1Q9Rz)@2{zqwN?63#SUb--@+1FSZA@mGxETz|L9X0OY-Qk*xqY# zCtQ2Sey+%dj7;xm_1?TqDiZ~F_WD7Hc7(zxeV%|eS@XvcsyN#v^Yc-)`Y|Iyzm~YA7Z=b4Bd}YA1tD=fO+G4 z?hFnQ>C0VD57iOCaq{cF+7!~8%7&7>uzRGdSP{1~rWxj63yzslJ&t(bbB92Emzb7m z5Z(A^vRNodyAwU6iu)C-u89osvwbHDL_Aiv%erPa$kS;%*==vZvS3uj(0+t-cZlzG zT2Id0(aKP?FarpkxSvfL>Ms6hpQApIk4$di5{9#g0|G(RxF%%93iaorn)6b@wHu7lSUAr=7qQn$5;y z*RBxZeW**3@Y1R8#*(m-RRG8?p#zxL;^LaIU$<*PY$cBMRgHmsn^Wx@2@iW+8S)zl$byr>JZ*{1SX$&fsV; zcLht+nG3R`GwOpL`iI3Si8d8J<&WYE9PE)`Tv*h#1y zWIH%rUT=JE?oJiUhlJSh=ZBm-n!@7rbzS{nD^}Jq@5I||2)q*Cn@jwit1$o?M z<33{&(chk4E{!t=B6#`33G=?P#BXCcOrZOiUvOlRE}HmV#o<_l>)wu&Jq|7(<`jvn#GzFqnvb-AKNrI(3(Y8@!0v{hbx^M4R-LS(Sl(>w< z_tZ^4@?oOS@mDNgi;XDPxhDzhEImeh(rqxA)F<$-A#3kUYxTX!=sB+zYi9O z#{Qu#EDIY}snWfB45r{9lBI1~Y4?Bftd7*J0x0DFwma2D$Azeg= z7yPsIbDZ0qeOOcL@Y%I@R;J%)g27o`l9<1F(W;V19xYYPyFn`x_hW&HUv{V4W=Y>g z`>T@w`E%1WOmp!E`?ES1bF+DNLHI`7a7z51d)T6h9q&lbtR2PuW*A^3Iln>4bQfgu zTvO&8>RNuQ-#=yS$nJgMEx+I;0JlsZLEs5HM0#BO7;zIl~}Fgi$pC#?!qOY zCw=uIJEaSUkedyCcGMcKn-CTGG|Kh*J3w5jXy4Mg0G%csa6XDcaIJ7S_5-+5c|son zC+_3TW)6l7o9t6#u9GRWl54e1BDQZ236AAC!;Ay5r?7^)p9-H9D>32XTF+R~ z?>@IBa~3HuWYCu+vH=yP11{(fy=^@uc_xAk@3-S*#TZ7&ar#kHiUR}@ErxB1V0`bk zo-}b`Im5b^b@9D+r*AEg(ZccyNxon23^x$zO@gQI7M$CeTg7jT)ir#$5rpsxu|1W2 zkfr7ZO}A81oo4t#8*|;@r9F3fqNl^X8pu-rsV$lM3u2HG@+TG{Qd;`}Sfu4Mj(@TJ zJdMXW{T>_sEPjc74yKHC<{BVEHbRZG#}69Fox4m?AK3RM};lM8ts1Xg+IBo3zsj1?wFwdl76GYEqc1 z&V$iy_J&y7+82;G{4YdAw0Y40qj)8*tSyUB%VP(t@ifHtXk?V3ti{(Sv50Q+ja}6H z#O!x{wl&@tHqE2&L*#K0bg~jxbbs2b-rPtA6}0Jv83wFk7Ns3;{D%@naUb6RI(E9^W8M=q&xv%PTwoOEb#cm>#xn0+m-U3!*wegdph7GGvQHH$A8=D82Z`gVS((R`a31o`$z zzZ&gU@@=4l_t%kMA0SYI^<8OZ5p_OVFj&~WzjNS%ISGu>>Z~Q~O4#)bpfmGvN(V|{ zEM|blzDw?wp9*#obzGOL{{}r&wp4+NTCjK~nB*z#<-4JU&i7^UE?ObRqZ`d8Gw+wc zR{$yosnK9I52)S-L%m75gJ?=ommSkMh&k9kyJj@7AZWGFAXLT zS-G<89CX)4Z=J=<`^Gx(A!ph&<_VwVYq*7s7=L0qRQDaT22MF3pXj~X90G(rc~FeU zUAX}valHR(6mk3c%R>lrS*!(nrmKN^r(IfIJy@1>ORS8cid-CMRK*XC<{CWg2huqM z(Ch+_CJtl|F|1Ijx%H*_J*bh?Y4)S+JGnOoPWB+OW+6g_-v!QF&w>cb!(&+0dd7$X zrjRo;FP-=cW;I+uBjU#%(sBAL2)tUW_ay;UHgvw728)_NLtsy+RIYZKjU{a_ife=m zQ5J%_SiFWUm~$v2MpubG%Fju&a$(S?FS$s!hTFUXR=uXR;f=^O_$IVRv%(vRRg=i+ zbI>?Z_dofKg%?P}?Auk<6V)&t$9J$w^vOUz{h=l0zC$PCd5C->92q3{IfoKIZ#1?^!o~3Kn)0dsjN)QYsOc5!5 z39b8G^<(OUUf;^3kl3P!EJF?6-S4}NustN4NXSdeK=1Pa2+VBe)c0^1=t2l zu18_jKMlaaQ2{ABlecnFMmw6K2&DQ_PJ^wIMBi5oYWv9x7Yl9`P#E}}z+`L|WNzy3 z3YOFM2PE!*`sV>1F>AzlmKi%&>dJPHT2A0pf)MG)77Ry4VZuUZT<}65>eJ ztN@*bp2ZDhbK0mh9@J9+zhmP4SUwB*2wNhlLeHZPZ(e#5&2WI&07G&xziN#gy2%mjzEhg zEkx{f4mR`6qx`9#k<4^KkKqy{y+Dl0VKF-=aX!ON3WMQy&vcWe^dh12lY{~ACH>Cy zMW-D^3|*1=O#w|OZkFuB1>C7*Ya7UEXl)HrmwptQ^{DwPdL7y${=)+FU6h> zhTpE(9<#(oy$TJU(oCoep7DRftJ{$|-skqSmaNFFtrXkzi;PkFu?4|)O{~fp!OiVv zniheo3kN-IuRnowjT81r%2aVcp4g5#mPVq>lnIU2ce&U~?J=NG4Y<*}Ni74kYbN2P z3BT`k-AA=8O^!WCb|CCcyTfYs@NR>D*T?K680?Z3T#15E zIL_$^_>(E--SjH}e};f{ospu}HVn^w)Cv!iEvg>NPO&$qNm~!!+GY7+S19!=hUuG! z$;chnW6Sp~QrM3894+Sz0diQ{+okvV-aM|Ok3gykuDe2j&|{Il+a41+)x<8{FNk=V zmGp4ue4&{doV0?zb*}oRCV04*`z7yHIhU8lNRp8XeGtO8qH_APBelM>_~Gt#hb5KA zz46qN4q0klBXZQQGT36-Ept~3MuM?_aANYj@p)rM|JIR4R1m;YfFFcVzdu2ASI=8f z*=4FJx4J1Ya^mt8F2GQ5GrFK1#cr+~gju`!GrG?>w54fMKnRnLczFAkhlBojPn;7S z;eSMonHTFsVUE{EvLbXj;~UOQ;W>A^B)6n1gYGv3m0WSVOImcVL?ePI>5Wc5^F-vX z@3UlRGoI?t+E3F*Y!K<}X$BB8Y%`M$g7|^JWnN*6!1~)f$w7!)r~L(HO(r$AXY4sn zyM|itMgwh+GiDcj2nV6Kb7hy`4@OLGjBj_Av||2J!E!inbuB|lPQ~BF8~@$Hco$3@>~LstW9))J-Hph{Zvh$r|kf(N3pouitJ453~e`VlpK5%?@p96sPWYAp8Ph zqxsaWie(9|3`Qwoq|$kWu6KCI5$&a1MO1B6mP&ZJF_SKXCfwIyaR2W5uu7AJqF&@? zI>1P-O?82&cgCaOd0ofHbspC}U561Y6<8u^H?tVXQSUbaD9Z{2h+TR%hBptl{E4s+ z7JjYLWQ0zW1rdRM-aC9himt7I5;Ilmi90v!j6LLB<4_d2m41d~bws*1s-;9m>r{WD z)SK#_(W&=oirv0785mKW+&qp4_DJ=}NwxRMRI?3wh|GHN{3Zc)#pQvqbqGq8#J2A} zjB9mG)7~N=^YXehiRL?$I1L^bw5NEEW~uQ9TFfob0{b$|R7I-8W6=6GpQw3-A!b=& zWsNf}Zoa)XhTi4sdE062v!P18>QX)xOvD?Ap>s$xx2E^IE;{aEJ3aqO0LU(ONaK!>HalgMRp zMnn zC^dER(+d{Gv+H)OU~U$=Y^r`Rx=)|@a~!CCr3Bkje!5;PDd3jcY36#Kr8%@ysOk_$ za1>VcmP;4zDZ0#a0XK;83D}e1?rtV}A{$dSa(J&(!0^*E2<$I7C@6QOnpKxAYgLVt z&dN(Tt&96GZ4yR^(jbv&*+MutFa<=MCK(lwBFYQP^fJHmTXgp_VG2 z_abrc1^Y8e%(aQ`pe*hY$= z(5aMDTYF6HN9><0y~(Ms?2w-*R?;44T?GLo`JQx;UwHhbK!>NDdK#J8-D19?ndV=E?UFFMJ@n1W-whvYp_fWvwK zR;jO?ScTLQ{8Lw;zM(RK2|eJleXU~)phSg(<~OmqLw4=0+}zb-i)dHa#2{8l{pqo^g z5|fPU%Xfq0K?IJ$97Mb%{SoXhLgsJ0TOwP<2NQpxldj_~vB&kV2Bi3Q0+*=eD;nz% zt$Hq-*tZkO7yz?;%VFfcl@UJ9&T!Sz4yN9{g%|w*52FmTC$nD5kS}_PqH&Rc?L@v> zxIZ!O_#l|E5+Ezzr!yuG6lxlG5xE$QQhSW1^7kgFVl)Yg5NsTjtgv2F>qsp6S^QP0 zra@urd2y(rG466Cs?L@%IT0>uY^o{-%?}>4-}^8cSGfyX0V14Y z-rG(m1$iN-l7xQet>Vm=*bd4QZoYW-3P;AA*9SI}F2p4p{(8VMcGF$;85Fg2mIrN7 zz?jsiw2&@1vSfF8dUF&xY5Tl=t z6jMm_q^@Li{XP_5+L zcj+i=G-TkCkoSeUhnYFWEY#b#zjm~sGAs0R{c(ZqtNIWyLDvf@+p6r$58DOGX{ts` z8lKyhI%AIsRWc`z24p+ubhrwj?oc32l3)UxmWPxe=mMYwuFmd)JU25XF~$9Y&7$+j zHk>79f3Aey2cc4i!`R;BY2M(;+qv-DF7`jRJYr!}c-aM;UPawR+$xdFx@G^ARZI3* z{=z1t3sM7t6Y1zKz34<(XbvV_ z)coi{i^d@as$q$RVGObDtl4?>v0)fw|I?Uzt#2XhlF-&{-H6!)vUq+#q^92w7RsIt z`M}8i9CWC8%h&FQV@7vnzBlz=!1AHOo16Kg;`@S<*q{X{y`L5ZJ0{;O< zS}@#`>|!)LWn>bAzB5Rf=Rh8RKNG<(5_+K*_dt1Olm!Vd|CZ69@ekuMCu+ha;bN~X zXDd!2d(1tEwo;doY=#WtU_N0_IqElRMryxB3z zBgPqX2{BV}Mkp>{iA?j#99P4j1R(*J@9x9OZT?pF?P8f?{attc-8lzW0dud6B)!wG z^y{Hd_azHiQ&>4Vc8RS_gh1$}SjwkpI0I^2RL1@LPHWx*Aj~&Di$f+?E;`*RZ}RWnrNK(O{8=qnFmm_32jGNivWw( z(o5g4P00B1I~ZAwlMG1sD%g^QG}qyHHp@}^RQzNKmsV>dzPXFN*Pdk{V*uiBmAoi< zWw1#|v9vLX&qXS`_jFBj+rSOof#_OTj2dG|mmVJw+_Xj(aQycfeF$CJ|D47R90@WT z*A3QFc*+_~M0$4aEcZ27j}0D~7SA!WMP6)XF&N~OJDdSVHSh*+OdXM4N;o%>pihG4H^i+GgiufvnV8Y0)?RUuy zn_sV2(L&?#Go$P7MDs1L$48^&7=`_+_!+rJU`llV`Q;4H{CI1Nt56J0nR}>%_WZ63oy5FqB2Sz@b+OD@_Fz$8$@t1&Y$%$ z=haMhD9QsbKxFde^6GBxcjO#rN3wAFw7umOSDkU=BKCUuViS*W_YFyv&(&rGzEmUl zSR+s|xBQ1p`n}z`C=LsSJG+uJwgV+=S%RsBH!WY?PijY$WHbNY^x~J)m@pc9yQXgQ z!fI-J!pz0qb4GYmjvrcYA{hxO!1uV+&B`)w0=eB{FE&nzwCH@HaH*m3B9BC-RFDae6Omd+gz~yfp{2a2hXO zdMm#dGx3gqCy`uqk+`f8*bKn6N`Xqu78shEm)$It(i>TV%a`WWICbP#elBVc8*8j^ zkYRyjGF?NAE=dI;rDln-4`=uyktYdv&luu` zYOqMp$m1n05Pp(s>O%ir1DgH??fEI5f<_Czv1=-&eNs_jCpPdRp@Jygr;B;m<_-{) z#JqcliAt|$Yk%g)juK5pjlo)R{``YlN9Z@S=BCd0QF2i5j|&2d;`l9hzs8Ca;|Eux zALwuU#rKWZwh0r7FD3a3a;?Xnedvid!dYQ(U4O0t%pU-a^*8dOEWJy0NauoqDlz^^ zG3BqkvM@^8Fp8(2L0h{}XzfZc2RpJ8NdQKG2B@0Oihx;=735C+pgn$0#(BBr<$oO*w(!d?!h84_A^unhnY4hrQ} zqIqB2a;-2G7B4xoP}f;Fe2Xw*+ccM_!b;hAyi)pXH)JYsMlZu@v?Ay_3uPQ=L4Vu> zh6&a?iRoPqcKnsuXhs=_W}zB27#p4R;Rut8PRAq6_F;uSm6af2MASQo+bjH zCR~8Sjyxjx(X&QCDz-Pg9>XicbA`d(f8bNtR@+H8v7dakS_Dfr zs8n_{c_PC5041!k1SfBpaGLpA-jH6VAs9<)>XU+rPmIQkL{>VYG*&Qz zk_?3`OHhKB3_GTHqK!G70n)X7%Zck9JiO)$4Oe$ZkxWeM8W1LOhJgNXOhb5O5e+o< ze;r#tJia3FLxN&QNcs*M#TDkvf(0dor?HYu5zX;`NF;7|T(0#YLlzu6Fo z3YG`DpBy)k(`fn~60p)Q6;yq5Bu#?>TzM$1q}=~2vuuIN7e4%hkaLpj>-{jJ+bT<% zVZX-=l<{1R21{2F(N=EgN_k$(RnM#4c*(|W__gi-1l2z(z(b1$O<|(Rvmg^@t}w9F z`aQ1j&m*a|U89+r(?E^9B6zfDvoiwXWI}UGas@3Q?`5BPre}0&J~`fTvm3g4INaX_ zPiA>`OMm-Me!g6ym%pCftIZiR0T_^S#H`&iy^XHI4~Eu&Z%?0slzGiMXiDUQ}fhcr<4r$?x3lDi9p=NORd}BK{7rw4)LDMG$Y(R1&y?IWE_dT2%CdclK(D2FIbWyg|T46 zuHDmHN*=BUJJ;M=v1D|yYM}hcdeW>$@E_PwR8g85;0yQ&-(~UGeJ+KFR zK$2J(SkAKYAft{%>bMz^Tj@W6h0*V%X1j*=A^&Ff16rwt+OBkafKzF60_qHirRL0z z%#@wb5Btl-4Fx1{mrQ@kBdopYJxqn5B)nk3nal)ZB_~msqcRk2xbCnjU43Dh?Pc@< zPdp0SIL^KVbxlGF?W0PV{d`F8c`gTDQA#0d)gMZ`ZCevzA1+Q`q)GhD;im-dw+En0 zK7QD(#p<-A&@ebUJ`=3v6#5NC!|B~vjDFDDIDMAP@fR$x2M^{J8niDM(t`amsaKo`I;kG?YSP|T!B zOV)`fc!@93<9n*-3L`N%2y}S;?sCJPDK7m*OZo|QbC2!JAKAg8$pI|)i)NmGSZlpN zI|>huCGv*tf?#~RY1w0W82CQ&cS+PIzh+5E4<^m$rsBlRLj;@()kWVB_ z?iGVp>Y+2O3yQV|TWPVMGh^K?>R~y3fx0yp|2?IJ8P30nP%1kyXSHM{kw7aX)<~QgkP3~rS}9sv)xz|NrifvJbbWpYUcut=79>IeFmp-{L2w~P;w&-$f0 z%r+DztVy99LXl0%`}<=OKAN)ag`=pW1uXvd0u44@pJ)*C?MA#{0aHvS^crlgw*AA? zRGEWZQ!z9`JitH*I8oNu%)$lehm+iT`U-=vF*zOL8C+u+Q29G7`>eZpJENZKMUCes z6=Mt59PQ=uzn9 zxX29~6{(G83pbelZiJV(B3j>E^JJ1sF;tjuxds^bbzI5(MFxc99y9~C@lN3x36r$_ znE?-25wnFB_!3RjAkt|P9=nt8zaU!Dx}<7 z`9xRNoqco+=ZL7_VQj6w@kVcaPK;g{RK@%gd(Y6MP0sUh3vb}D!7yZ79J-*S>I4B; z9F%_LHdldRkwov{VilI{Tt<37d-`eV*RGs9Cev!5m4xRvouI>{tUPQc5;1SP>#xlw z%(G-r);b))?B7Al;onwe=@A?K^NJm(U;`s#Jbd-b7@1dg2m3dW6G9I34e2a7L`lbMMT$iMbbEv20#8cz8mFM zao#~|+Es>YGml}Vp;>0dv0K^RUiYENvBq5PXj7&ID#l5Rf0-5!)kJ-8#!x8?Y zc<`K402*pqww$g|%dU@F{E80Bb%=;;uxLi{)Y}%my2PW!P+a|!S;TKBm>4b?R4I8< ziZ8Cb*cmEbY8$nmMn65Y%%|vPbbk$oC}Kg-FKo~b%SGou>;*3*c&}VO0iaSP6`2t) zC6+$P-d#SiV;C&}rMZZ4?|LzPx(9vXBT@(a!crZUzl#9<+X4_2R6mD2UNf zJ32w?P4v(avq=gx_TrrOs-^6m>X&g*Y;mGEa<$`~^YnitreUs~h}-tvb1)J+Y=0(K zq6jN)tw*crEM4W5O#96I59GDVFui4T+fy7k_{S}3;u+1% z@zt|#GDtu1T&Vgl6Z`4cSMG9}IX#M%V{iZmqUN=q0|qR*>NK>n&(=K$2DG0}4fzWo zJ>;;2M8@DV{SZH9szo1?$Y7EE)H^Cq)JOX=)GCJp70O`rf~-@3it@Ek8SMpV?0G+J zw1ChGu=-#rI8U*_TKg|DcufX5>d7tVLROl`eQCZqD5Y#GIUAz5u4Lz^SASJB*X&H3S3~KH1 zAd@p-wx!bRI*hK;vVfr<97be$`Q8pBg4psaWXlQ<6)MG~L#F#pCNroJhG167Q`uK$ ziOb8%Bv;_1VASG*SN3E_X(^BV1`fslEY|?GU6QY^^_?mK2a2$wc@RyW;Cl9||iTx-FQZDrFw!3Vp_lA2OP<1-0zAiI@F$~8H#j61}*p$hlIQXeBPiOKDT0n-Ul=9&v& zazS)1^s8jH`~pGwtKm#SPyT@#uZ(t`4{M=>2%_J^jg;BO*>F!mNM&|94tI9i$pkWP zAb;LY9RmngdBOjZZGp?QG(0-zEV@#3C$zh?i$c!b`PWLC%d|1dPg{pYg{L`T!D~Q> z)2klJkY#~f8hbHM>!;N-R0qB327X+t#o(OZ@@Yd>& zz@>$Mez~NoO+YHyaX~;vldj5DW*q4yZIIh*e>}70N6bTmJ6dWoebuTSo7Nd$K@J2{5uO@q`fL!VBIFTC7}$#&8SVvebR$;s zZ+X^j_yh`k7t5-ATcSbjs?Bld<*MA)1TMrYqy7^=VO-$HI3%lRDLI9)DZ7%7_3yoy z9(5a@o682(2H74r4MU^QnMB+$if1kC>gYMFv;FTV4CnAJ|+RiOpP1KikxT)(5H<=cY$*H}+f{YPw_z!a6 z(9Egx=IO!Hc&P%ANo4s z=u$heZr3j>VZdcVxj-LHobxI`^x8V zSGF2iaV#?Iji4}A@+^a{h!lXK3D2G*=h6873t~i-Tr7~4)D<%h2RtP5juL{ZlL4<9 zL#;1b`hNpYDy^yT+7W_6ByG@;ZkCix`PBxnRLnv5*G z{nODtVoO4!@K5rg5@OsNHcV--a zAsroYPZe$W&8OSoeoc4l26vHMBB2Ag72c6=L_UjKC$C1Dr4R->3|H^aGC7&s87t_e zm@##%4%GHp{vE@=e5%K}WttNC+Um~Z5!e7iH+-&on8;S!{y`#&#N|hAnFI+cdihGZ zT?H(zN&@Rt;i)A7lq#BMnyi4Mz)oL9U#J~7YLr`2am6|O$umXX$V+483y3*qddOwE zr=iw$Lb<@BHevL8Wbh$L1w$cWrsjX|QJ~O_{L|S&&VYMxEf7!_?MTZ=FFfg_VN6)eR8MOYlb80Otwyv=a@hGYsA=>Y2QWU=SJm~h8)22zx>i37yXY9Imq5 zI&uQ{|L%D}k}#st4PJuwh=RCfAEjbq%*TTXmEhq;4y_$+1^0%LzykK(g|hh_? z$n^PdxUlbVRu2^vPu=kGKGqB-Jn=BNaIzaMNd4e_9q=GC3i?q(roCgLQarluFt~aZ z8-zHxH~rqHhhFPf6qTiyH(+l^-M&Z2o`POrDB6s7|ES0Pl}p0(uTl`e9|CL<+vm>z zaiK_2`@%H<73)O^+%_Na4b}$CKXdc3HPR>zfdK14&EBB1|cM-&mY zUYXw?;(tu%9j32DQ|NTJ=*8ZUr1><+AQvdB)w*_7!v1+EXU-asp6-2J`fPi;9O9YF zl)s?h56q9N?AbO{|JtYH1BeyBM zRnULJi$sIoOIqaRR#&%evRq{(#^XN``S`SHvv8)eOXknglFAY86k)cxOnjk6Q}ChF-(nl(izB%eJhIWlcc2AgUWkwgnfteTzWnWL6$9vqw3ZNO&G+%p3# zw0kCeZGkNHrU_nhL(}wl(<(!lrE)_R&U~p%8xR2MTVCs|V5YU&trxX=x37^B4}FA_ zFs$7X#PFbDlJT;+7VU}+%rQH`WwOpbf*=KQ7DI*Z{4cY&BTk&I!KQtd=GQ_K{O6Md zzstXOZFZ;y2}9iDx0V!c^gSh!YY$>nSoQ%{N@lQ}vJ}E&_qmYl;|$3VXgkKf{S3A@ ztaiy2DTZ#hKw(kV=2C?7*lF1i-F_aQki=Pe4Gu~3pT9w zy?f)2i((u7{^A-FZ0CjB)#ub~eRDNIGY{LzTd21Ev|b{Yovvo&YgLUaXr>svO0{kM z(-ZC=p?}h;DIz}*NDg??~j)Wf|s6r3T5S5qAb2H))z0kMQ9}jS7n>e-8#yHCJee|4X(K zhAUa{yHJdm}tufN!$?0I?Dn`K4*FO3w(dvV;I;DO;C>>vZH#fMRWc93Yr}R`N zu5X6weg!s06@LtjlC@Pc#>tIMu0y6no2Gi&bF?kVVmIZkAc~?MM!#+sl;|kehT#SM zIg6&SF+=zg9{T6nff*eS)?73-(MWs++ik%P#1XB1Jb02MU!{pXQIHpbM+yPdyWw;R z7dixK9t*!po*o-@%Fm1AHo~Z>@0q#{p1~K-RCN=9d#or0UovnGub^U>51VQe)kzFbftnxL7-BV- zWhz3atMX*lu<4)jLyg~Jytho5U6Swy1^eONJutDZNhBC`na46-wbp1e2nJZM#vQE# zql?iU6n4m)`{3IIGxJVoUDR*rDLH!z#5{A0(P+uE{HS5Lhj^|%4>j5}AXK~s>=LA{ zp!pZbDdVUWfPZ3-Ru8E9LDjJH z4T;{AB5WiE#e4fMYI$XbHQA~5F3KgB<+8BIAEvA4dFodDR|q=&f)m7U8Zibgg4Aup z_*QRZB`JiyOuKITXiqnFL`H%sauLIWVe&{`o{X6e0LGD+wzDPpf5XG=$N-0?@Eg5= zq0I))>>?}($Kv+6kT*rpHjE|qt}}q~FS&gB!7lRq&8^qEbmJyS1%kxd#8jz;u{1Ml z2Ky75#;8NNIN|(9E~Ac&PF%2!@}~3}5Rf!klt`%YxRARso*ItDjn#TeX-iFJ=&TasimjH7apeqz+6n; zO+5UAJ};n^Z>mEmu$%Cn_vEZS>T`N@E=)xB%M6_v?}anrO%z=^WQeU30SzmO!ID9+ z6wMmLm!0ZM5nMn4tdmIi=u<>i1O^OP_Tx45!twyFIFTUOn!^TR2+@!=Cttf61yC$l@e|uguXG%T=Vz}U$ zF}UG>@50zrs4~*zmTdR@MG`&=rVoKAE#Lk$3q9hN2kK%Y&5!eVnyYVRkpqQ;m&MZ_ ze(XffJC>CBv*dUxeO3`Dy<6rS$3@N)28w%zwZ>LuHP!Z4xqa+k=Ku-@3C zZt(^`BR(PQ9bO@8qsRIhsAqNzvtzb2B%NBQT;12>ou{6Dl0*NWI9YAG10L^)Sh{x= zW1G!oj(4qv$w+a7a&?p!h*u+ysTs#5oa_|yp8ciT?Xm6~@HPg$vTR|JJ>PSc!)Z+Hp1|e)d*Ut#IA3))hVLUFfCwq`OEkWfQ&s(vfPlsAU&K0H0JUDYE*npJSIe9f?sFveAn0#7zvv1)dC)z zf%iiThsVcN-;m>jGD4K^qZeT3xw1tO43SlaFb9Qs91OPb?`h7}3v#}%JdEBt#j1C0pdnyvu>d#Z*IeRoaH##&wFsa4J?`5!Agj#!sk~HVh^lZ-zHl>R) zrk=;|R^-*2?Q8qE2_xY5RF41=#tM^Mdk|NTN%$gQBt?PG~5U$QWs7+5=+<)9@wgdSqKeVg~kBtI(^55M-r&^PNx~x&$S0 zlJ`xG7s1Ydaq&ZB6ceq9%^0$9qlMj8RXlKkAfg8;RGm&nQ$e_$!(|&!ksUaI8hAm> z-^VAGtIF!yF7lSh8u!0vSypzyt9J;5|`69>~s;|8mc^i24IAMV^|bm#<@` z!q7n84<{s)hLp&-5t)90y{`1+EJ#lpxIQ?a)FD2$GGc+r=yIqW_gc(Lk19LB;F^|C z*Helq9@r)*dHN9d9gG$b$;7$yQC0gCkL(&p^V7)lnGrp3EOitd{Jr#GiU z@o>FVIUBw^eVxI*FqMaE61#(`5X@>9c;Xx||z8$o5Cayb9nusdJ->(+{j%@6Zf`~Z1(r9kl!#EMX6ib*J6-(knI##t(wq41go(Qc_tz=htg z7I97U^7vi~R~4bn_elo100FPMcqycZC}c%wPyB1BSAFls&lgV$sAsJpuxND$)$ z9(pk5wVQSze83bkV>DM!#v+J&?ijEm?=0?o>0t`6jdlP|IE^cOE(2dd&?E@gi-6#I zD7Ezz_)~Yq?~bncY>_$^hJX!QJ1oHqa|v*`FRhh;BqGuwUhtBER3_(S)!w4P5DQ$D zW%UI{+=?@#@gjy?Oo#*H!AA*e*vX|)W_^Mkbv=)&U|*LM zeVQ17XdIYJxRY%Ap7zQHRWHrtI5#1G8}2(`a==~tlQd@iigIc(zfT6IK!V|7ujtx6 zTu{umma0Cu6z6|fPYSxV$jr!>JylyiCh-ASE}1u~*rs z%@RXszKkQoqBM+bHcLN!bAzX#;}58FPzVk#@0eal$?6ZATjHaCB;KO(h=YXOm~@)i zEtMH5EV;a+`IgDvHt~vjrhSO|{&DAH1b=HgPAd;FKL3{Qj+~coVcUl*+(uB%3!k>K zz5B^O8+4t=&@N0PWX@h`lQ;?abnw8oi-8ZosQ%`diQ*FEnG@{jD~IGuf9hHF)M^9> z5N~rBgcLeoLd$iYCTlBI_GM@=2XpAD%0OVbllXD)ni% zs{Fva!Td}8w1o{UZ^LsU%{b?VC<{Ar34H!y>Dng`NJIqEN?pL$Doc(59o`1sQOeggao?hjVQfl>sJaW zfL=K){>+S{7I#0Nq1j-6l0sm<2Ah5lQIE0pI_-q8WxTSec{J^n{gG3cRh}ZCyB9}J zK>dHxMHiv{nf>M)mA@9ZLT9-A4+Ix}W&Y4TKw;(MH<-6t)+$$mi7)q7#f@T~?njWB z7E3_3e#4TJbu(B=Rt2HRqNObsp!|S6hAvU;M7rz1^(%^7rjGiYl|g<*(V;wHPDv#-JKY-5ap_|C`vk}K90SHu0nO%6uj z;Gm;o&$nX!L(If#Z>Vkj`p%6o1%zNsnkR#vWB{#jtHDbvX}&m4q`J&G3M>Es^@iBw zrMBls(I#>=l+xRxd$d4#6{tccVI#z1UYSe%%>Qv7K7xW=Vi<4&g=?R9DwC^M4nRqz z!5Ehw-}i?!k#}+Dtes((h!H^Ijd8r!-xrzY+$JdVY+S`dJKAD-?St(5h&3GJsuHMs z6`7)J8Bv!+mO3tm*s01}9>) z(^)1(QrAJ;c5mIm)y_^Xwkj5T;Wb348a3yRlhf6A382~?`+uB~Fhwoh1}r~iD8TYz%M=w_%<+73XR~xbk#5vJ>E9u1zWIBM zTGFx>=m2=F8_lFcjdRBug0(V8sa4&Oj`nVQOp`%Hl=={Ff^P=YaY{C1K3MXtJo0m| z5;sK=&8M24C5Gzfditv?pM81HNAlm zis5FDx)fLK%mIHO>%#H-ATPoo=M{UzotJ7+vhLf;8OhCG`(-qJm*@#63-RLNo@wVV zhM!e#F3$RU7=}M;BPrm}9O?o+CS0&Jh8(vg4(oXkACcw6s#)lD(kU%}Y}Nud>9&kN zUQ-)6O0>3r^0OOr&V#(}ewi)~y^W<$=D=9K3sNCJXsE`Yql}bg=WLScf5QM~8$*d`Ut|{92LiZ{T%=b2?^j%&U-mo_Izpwk%jVxaN?{Dyj&of&yRAK-}%3~ z03vPpbxXG^G*Df7*2w#c{jBuLL}#_&dR=`JMRtZjQaLt@7Uo06XJ9-H*xi+#tAnOu z;tS(=8o8MLBbgxD>qX~iZDrMxNgND4H~EtkpaqC+cdJJp-)|;T+|#yof%SV1u*snZ z72YE{-T``d`NJXd`uY|CiaY>|5SBTOD}UK5haem0MBG=szkpQ@iI1L`KDAJd&p9IF zvZiCcbgzwpiJ5dN2ld9U!3XU-2A%eX6W^!?!`2THOZ_%wW%rc-vS;CbW;_t3O8d-T z7>b7E`=jFzLaDMCe&UKjconj7xY<8ZZ&%Z_@gxXS4 zi`A@Ck`bxTpyAlv^S31rG) zrvR%c#~fHUV&%`;S*Bs?Ob{x^6u&Djo9(QL(6xQdX}+7)uQ1|~sSbDAbTu!Jj7j<% zckdL{bz}91{s#xd!$h#kl3*su0Pu6B=L8 z8Q0To-_I$W9tT5ytUBE~ah})%%;|Y$uZm&?GVydn2K&v74(AAcP!7VD4WFeD!np8M z@E{#qUsXY_f*l@2Z6)!fa%K(Z*{&zU_RKXfb+V^?VZ0v^WZxpicXgJs0Q3L}9>GDX zJICXN&6c0HiK1wT4)zXZ6=AFTb%x#=ti zBtTOmyCL|3DdLAkHRwb)kq7gz-!wNd`+1!1C=e?o7ypkW;M&dOEQljCytHe4itPyTDwsYBAwu zNB7B3`*6UZ{=kHNx|>Sq?(Wke7Duu*9EEY20uNQyP3c^oSj|!m$kli1q6;u08R5&7 zR*(Z8Fyt`*EL0pF!0Kl|GD<5=hJY31lVx0#Kl?(8e-CH-fLdgu7Q~9k)irfGE$Kv# zkQfvWQ&Ts@OVLyK!k{m)zxz2dlM2p#8MW&%OJzJz73ed(Ut~wDJ@kmjnL6(!gO&bc zum*b{r)Q!Xd}MA7bllBYb(11(w`GV`W6F1AAaQ5NrBQ)@9j(Mw1y0)PvJ4*o60Ofj z4^x+|Ra)plc8RfUSV*}SG50y-wcJqCB@-I5+vAl_gO|-Lqt+w=Kn24{*o<67a48mHTt_^gJZC7I8q)kr1+SRURot8j7`?2?aPgdA~rqB&I_-Lpz zJA1YJZ|M?S&!rA}w1n1a=Q|*W>XOH!Y21E63W!$dPDOKb!74}eI|kru7X%1c#D72* z`V9V5G;rXA2Z~Kvvpl-H!n6wn(*a!-vHb=eO&>#J=6} z+(y5Hn|*UEKik!0?Lv0M{2FM>{HAJQEDg5j!DF7}`+rdgjcZ@4FJGA6wqQa0Yx)fh zBb4%L+kfz>J@K$X>n$?yaDQr-GhsxcVXK^=-lHI&fBGpj&lz31JpIbc8noWm@rTjS zz0dTvy{Jb7f3!IHSJMDhWl*d=UxVk{$R{(;7=&LZAUS%bAf3moWYr`EDv_1la8;?O zBwJWEMP=j4+=RG~pf&e&NVLfN?=_Ja1C8zhlU|RG9Wv=tOO-Q` zfM`%|BvaV`$Om*ti0G*GbsqByJ%32LqK6KkXhVJsoww+yJ@a#6)AuhhRTl~&QeEJuJvy*2 z&-YlB#;IMmDGrwBh@L3y`&p~WhKjsN&WeGH)&Kw}^jc7q&Jo2=HPPp8nG6}JMJo{T zDl2l4G+$pp;S^Yj@s0QVyy^3&FQVa-5jK&Ek?ukZYEo}py-R9IdZ0e|H^Dn#W*Kvr zO+?Jv42mjWUI2MsP zjq-DB()VDV2CwJ| zVV*Ep8$%yXEP+$K&$6m*ekt`F_a*cZ-`>0*_6O;`Ktoy1+`V^Y$cYXP*^ zJk!6f?VCcGtNQatDNjk=0o)JGTF9g2Mw=o^M7Zcz=!wucyq+sXp>P^9D2x%UgT01K z3s!Jz#j(WA#XriQrF)`;K70Q6l-g_;^;I~)FeP(-N#NPg?eck8pK`pp)9z88zS`qc z`v{i)>%pRvf0U*DTc{hsWsn`2aI4{q%mByAaQ32^FB1trG_9Wy0a538lfHGEJLHg< z-cP%M$Kv{-e6D3qfwL})fR7b9O4e{k0i#3Q+wVPt>9UA#?>#>}R0KA6c+H+mTL>o@ zbhIm=ZS?WIlNkup`lD-N4#D+Pl{IKU(vIB4u50P|%$2~p+v1HVc@C93dU5CH#7Y3S zid9s~mY~?|e;vpnmCf9ra-j<)=gk_1*>X3ATZv-}qfvbCqMZD+dD`6}UlPLH)8a6) z30J{6MYJRWf8%(SHw~79&H~spuuFMG<(SYa1%orHY;nrrNl9d{M{VA{gQzu>vP}Tga$3f&_Y6UUly_iZ0SD=#0$2fm7JFL~BF z`pkU%&VOC(80%}CL^-*hV%s4~NHbmU2`6%oVYL1z-y* z;)9qrhvZh3PnpPKZ+zCc#0`$)&b$O7ekSIaq=uzr^g3?YO}TdgZh_pAcZ7ZMl~dENcjXoZfd`J*yj3k`MX4J3H{}Efzo-S zo@0<5nLP#65*1j5j1_N~05O$zc^+-F+LQTu08iDJy9sxuO6gOyGkPw4I=#!h)0cgB z^L-ll6*z9&JNCC4f{OoSpVi(J|4%H_cS$f~%Qcw8}Z#NUPK^Hmd6+W6~ei{l>j#w9b2l@FOguSk@uH3k|= zndA8jzhj6~f_uBM$}WKhRZa=>Nv?O=YOuCc;i!zjEvbb+3UoxRY*6D6u&TF*Q51b1 zaEkOrBVtJF!w}~q)ezR}=mkNP~kNYLO`_4h8}@$y5S z4@?G1#B=-DSNwx|Pi6rq00002-Pu6=2UYX?HEp2(%?5qc#cdsE{q%dv`eSMg(REU7|z`Udmp%TG{BRC0yyL9QeE&B`L+A~GH{Uv)KajM%;}|z=pI+;tTbIa z&9RGq`h4$ml&0P~Y?4VAN%8~HX(HU#CMi&Pj1p5nY`$Ex~7Kn}PecFe~0YD#>ODbRJWmd|ONQiEv27fxbBciGv zcgpYeH^uwr_bGg3d8xk}(cWrIjz>NO8oQl`2TzM8Xmqp@B_VKCsrC;nuQkvusBS&- z!s5?3n$<69`L3W6sM-&Rqw;hH3Y!J=j^XTK1{tU@DcTvgJd-{kF^_7S`#fp9o!Zd< zMLSp6^|golF99&bn0!k%RN2`P7S>!V5*O5nbk9Nnu4r;684y}yghpGuZma4IRaO6+4Z zhvk%|08_oDD%2jVHg;XLdcl$h6bxonI?`PbJ=u>fMdk!_{Zq~cmM0yNVW+=p({i2+ z=f1#ql^uu=m~%g)+h<0S!v*Zyx~dIO)Ed8JkbkhmzGp-z(gLX%r7JC}gL6+L#xfdx@V&eZKIvL;$_iA~mYM<2(aALP;6W+weF?Z^zdx;l&&i$%CY-B zQj*hJVc~2tONqny2s6e1^XRlF@y(iqbx90^-P97AzB|8|7~9Ng1EByD)G53(>}AJ| zuAE&2Rz+vvs3iaxM^nthJ%|`NV}dAQ$79&GH=O!k91jiYUIpKo*wyB;5A>o*kuD!S zmsbNIF^&2mdGl|Yj3!<2;c6g+!`O`q;+48gK7)+JXkzM%u9+wNo^ka}9^sLv^_v~e z;%I2)uWrZ%MiNAy1K}x$`Cb44B8&oOq5RFH%2s07Z%%l@sr0w*&)-?t-T)=UxM&FQ z7`_fUx7Xn)zrSt%GrejHl`|Up_z)jZz(`FdQ5~{m2sZjTo_)cD`eQu$1r?R&^5uBh zu$UQ%beEGj5?bF_1CP}`%4V{lT!PmMLkC@gj3=0+JlJT{a72ax>AqPim*TCadq77k zH!@4WOfw~ljpqmM<}Rg1tbe0F^$&(AY%|O%z{*efgZTu6y1L-T9{vKvEO=Ll1nM-f z5L0m==If~g2uU6&{w%Z|^nxLafQyI1q%alIen>tY+Mm4AShkyW~8m*kSI{700b#dRfE%JYz5r50(tX>UXrm+hC=?9?n|sKqc&X&Tpe#`$pHpd zDsIPZb{25YXUn6NY>+cD+>FV;bv4S#FrR2A*GlJGt#6|L022JK>wgJ!I_$i=TD%}d zz9Kt5bP`5Dhz&-s@>_(KBE#c+(JUTJl8^i$mw4-rq1(I>R2{aGK1v=SgZI&CVy@om z8=JN7QVok;m}ZrRqPvcsPWcI_nAz};My=8C04bOR^DvvmCJ*AT-e9T-Fl}N)F zWm34%@Cd?fS9Qq3ckdoc8oMAvvP?fCg>L5Xs1Krr5?u9_bAER3r@t^pa8F=*%P}+; z16XF-I19l>NdD0Y3>eh?x}~$Gf&dGmJ>#^1X?fFw>dyJ9M6X`CECf`CSr;C(FghSD zYy@Rx<9*sd0aKFzY{LqF_D$T=Oe(4C+oCB;Q-n2#SnSSP9gH(d)*P(Hbe1`q z{GsvIy31)ln_Ga>;!qL+*|pWKJpadPy2am2du2s(nnC3awQ9?X?gR97!Dh!e6f8mY?Y7o2FO?y`GD4}u&4a?cKG$}tU}tq7FLVWx-6Eu;leZPqjJ z6SIF{?P5<&zpE8zQw><|7A@%P&+r3edW+$Tn>R+e5!)MWD3_~Nn=3jmWBPTMB=IyO zbrUJ!ffHJ3sam{c<1P>#DGwy*Qn?+>-~pBxJv3D81Xk~CA1TNo_rW`V#!t=g zRyyD%-!0&}MWRJG{$?H~jcmgw2P_RebyT`^p+Oa;Z0xhZze#p`tW9;Fq1XEywj%!= zF_KH%c7j0Qyz~uf*QnT?=ijbhpCL2d(!QYjzuv7?0y0cdeNxq4O88grDd&9hypb(+}vA5%O&4j|-yd|j`EQJ95<)%uf@mT_1V$N!4XJu?*rffE9HB&!{2 z3u4o8?`I7?9cIL{_bVXMTA;D6o9!)AEHq2$RMmOGA@b;`jfSoc<$Vax>KfjnLkcoy znf|O0(2_A$`cWr}d4#SrAQW1wg}9qy&Qt-l60e3;-bX_l>sq( z3A8N)-=Ngh8*WJG_yXi0g)c!Sas!Ubj<%wtH9t zp)J=_YdXs&lo4EejUO8(x{JReL7NFoqPGvPHva$8S8^1%?3pKlzWd8OzieH(e%>4zG%8iWHw<-sR2-P%qZ2yTB<5WYN+qP}nwr}ly?me%y_m_VeGcrbuK4wcr2~kma zcK`r25kYxXd3Jn|f6sQAK-qwl0HAX~yzZRolEj5nr1=Zs;reip7Js)!j^Gp~fuBF= zi{6P8fafzgMQFm&YAMJBL*aiDzWpZ$7k?N3nmW``H%D(Ft5xt>d^bK*Jo_H-jsFIJ z4?NLL=#}vWeh>Z}KLfw#&h@DMSbf=Uz$x}-{J?ytzTWQ8O~(%7?fCBg@*Uic?B4W_ zdjkDPeRY1by~sJ)_W!2uNOk;H|K|SKt`V))7W+>B;`{{u{C?DC<;MKles8YY?)i57 zR(_^@0{{4b%YB=@*>1<)tG+qdO-G1wx z_ip)|daiz3eUtt8KJEhYP5w6cZv28?PycRi``*y~{<_X?{Qd$Jcf{FT3qes=XUGpk zQkf>v7fh@dnbBOAWt=ccqAQqKFN7d#Xv$|I7PmB;wY6TJN#6>fpUR+g3>|}3;`66< zq}1RMG2noYqXYk?ynX>Z!`XoCqxkb>Bdfhqbmz{|QQ-U06YcCflH3hEV)6fbQSl%0 z8WVm%rC$`6fFcF|jBg&!5`lD-i~J59cL)z~$xOmQM12O9Irnk+{BBwEmFwGgmFe*} zl;3DC`W8!mN4L3&eRWcY(mB7DH_GtyGpR~T?>fUummi}v#L;%V=!?on?7kG9u`gQ{ zy|fdM`WvxBkBYBRdPMa-a_W|iBm>KmyMPEQ@^~DCpN*s`(Qeg3SR6=PofV-={nGeK zdES^KNi1xjPED^cR(4RAsI@2ix*MmB+X(P?_C}};E;=LKfZN{Z1H}38lw@tvBOkVk z+9^jM^;e?%ZWSMmo^2sx=V>9FGc$4$M!Ma?6O(FwPj(2=CZOrm zzet8mwym;Z0SBy76EA^(v!h8Ekg*+>8ZPoK5i`JrBNXGg&&BV zo+4MMx}Gs{42RCws%$jSo-#lfSJh{@|KCT`Vivmnsds^{+kx^YyrzMetXrpIReZw& z58a9oLJ1FlxG9je&+MyFikTT;h@Um`hU%FfK4uBpRh#;Y5u-1KdnEC;T-&n7F&tzs zqQkc9=8#$o2Kji%8ZG-_zWC=VJ8+%u0%?05YhNU}@qb4}i#gwC(2^1wmE+uO$+IIW z*~B=#7;8!7DULj&erd3Cvbl6D(8y2CN-plASVgY)VIViw{BRnuS)tDd!N5)SgPXEq zSGkm|f-A?5Iyt34@yf!5xf4?-nsy}BNUFXV82VD;i}Z<8IJXT3^5j06w*O{VEgkG| zpBL1O& z@VV}ZdbQDMQ*#%?7$}jsNCy}gT z8M&>9RTimPAa|sbb!TcO-`mKxEyS}&l+8E{4D|hA>wZL%lh)NKYiG7AR-W_iSr9)y z4eR6HG21c9OBEhG+N<*Y#bpOW;<>8V)mJpM>9Z~W{Xe}1VxLB3&5``crkGi*$lEQ8 zCUZg#P$DX8k;kEX`jZptq1hwbn{KZ(_qB388H5-T^AY#c0k3KNT{HSFeSF!Ocy-SE z#>`V7cHg7M=`o)^jTC?gpIb`M)2Zvs^XInvQ`;D{bvIqHlsqCmacuQl+fQ01kId@Q z^`O3I!o@9U;rVNOgb+T-m-p6( zGcVZrH4;Q*;ciCFZ_P@Ns+4+@!2pik&7AZ`=SrZ?oFwg-1==4+tsya&<`lN7_GO_p zMrhrYWnz@nHe7@`E{-c zA1@FD5?vP)%y-i3-CG}K1vhuw1r_;a0o~M1m`~!NFKxdLxU$q+KKE6|&|nUjL0Qd_ zM_vY;>PUwaY=)kGT6-D(*LqXhPr1pu3tO&)7+7~=)XB)@Pb4oEQ-T{Xwr6)l?27>A zA2EMge~EY5E2c^f>OJx+q;mWcXXwAs|5`f7<)pcb1Y=u&|*O^>2qA* zZ6H16L|Vno2i4JgqgGBQXmVJtgK42LoBmjtgm(W>%?x%v>N zr)Lhj9_FOBsX}*R$N!KyKbXq32K8J8@^DLC4)t3SSwo9W^EMI&qx>`yi}Y}S?J(y& zwEja=)cB*wLCA&kF_EL$v@Sw3Hw@yR6VCPn^PjHqe#=(G=%xS9uB8&a1Ahfy?U#Su z2c_1b)cNP5RUD`Gxpa`X{g*7HfK#OcUKO(ZsmmPIU94 zZ;tKW9MRwSWB(fSzjkWkXy_+m^ti$r5f$ra0C7dt1c#B)_&zE=Yek>3g1_Fg!%G63 ze4etaFL+Fsb@OS({yRYIl6f9#HN#weI!J*8IOD0rwn4Pa^Lay00rTzH?;B0!0iHAg z|C(>E*Ym;NDY-3!G&oBe08NEj^$To)4vDCmr)0&I?Vt8M>xeRBJCORg@!x_1sZCdQ z*y&2|9L#WE;xR9Nqj5GGhZ_e6)Kq*}lttI5PKm{O4AS7|<=Eu>)osTZhc*6rMlkXn zNs*Xqs@T$UWBU(n8OMFgm$}znt;|oYWUHihK24ym#-3?mtLEmb3t+caImVb|+=KnG z=A3eVFskY_Ngxwnu)M#X>AN)fzrF}wOsn2_zvTRz=3WzE0T%|#h3`ASw!24Me-pRy zd@i2-;{R+=3x)W`0W(CEPBj1*OvZueg7p~&nf}1lWMLOGpv~eidVZE3#77Z1b$Y8O zY}P*;N)YiQUflG>g0WiT2zW`6FE9EA0b7~>7s{yfy(OYD82($a`~VjBf6n&|=)o`ASaa!WOTMOOuB-JdD<4III$k+U5pFKnEHEGl<*ciRTbqnvxi^4r?ZVx8l+_H?1a#mXgdblq&y=JY&&2lW zok8R1q1gE>Iyxz4HcO*Oy^7#~Eswn};Hl<2%qo$O5n;F?!xC30-rp%gJNU2vEIQBD zxPJirk4qa`%o(xR1|li@-5D=*l2Hzvq?kob#oh)`Fpwd}x6)C~_RcJixGM^0-1+F} zEc)5ltbS&L$yZRd6eD0-NZcpaPb`UoDU*^C911*|GU=T!Kn|)l$D)s9ah24`nXLQ} z2t-#FV8{*r!4t0p-YXK|JOk9*=|3O%AEzV$8?g|KehvPiiPjRHbBtsfJ1K(zv1AHP z4M%pdQtXWG3kEf~$k_qv?d0~Nj?-Fq4+qbveVj}Ap*;qoOY>LFT1PxB-8mtt za>zL8WP_US#4G;Xku3r1LO(?mj$fPvfTGt*@8<$O*)-+N$JXeV%5-M{BG`0DoL5+~W3U%H9<#`q>L z*j9E*(rlGRTLL?>*FhUI>(wZ6168K!%WBgf9D)LW7GMSx7F6<@5;tY{TAGLhgGP02 z?W3m23bG4gA}=Z*IDfF?agCPUI!|jlh&6)lc#U#C`m=_6jZYaDEv5x=|i6tMk2x zD>b7oV^L_+<;g<>>w_H?BZgD>AmYp=`K@^Le=IcU(tHmop8~Lf0K8AqQ z0un>v#NwZ2*^?3cFZ%0A<%=XYY{=iPPCrbARTib?uE)UfMUVcn0xqiJk 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

gfaYLs95QNZ09eb$*M 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­$h<;yQyapEsjJ{`5IQ!ziBEx&1fcEy=da! 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 XjB!{ll4q;sa67-4BaQ=ZfB*mhu&Z8+ literal 31226 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 diff --git a/wiki/design/pr-1125-verification/canopy.webp b/wiki/design/pr-1125-verification/canopy.webp index ca0f0ed130dc8afb447a5b5f979fb8ef8244ca7a..9f48abb18f89d88a1cb06dd018b3fdd86e1a4c2f 100644 GIT binary patch literal 37898 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 Bu`B=p diff --git a/wiki/design/pr-1125-verification/hvac.webp b/wiki/design/pr-1125-verification/hvac.webp index 57fd47daf51f74ea7620ead3555ec321621bd094..a28415beca16cd723ba67adb18339049ec0de2be 100644 GIT binary patch literal 37330 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%0D^Af5RnU7 ziKII8yUy(A%woXO8hq#vF5CxM#Z-8NFM2gW>6>rpjVchq%dkjOQZMw_Pb2}g~*k;AR!kNUg9=I0!BGV6)tZT$Fn$3kiQAZ%mUI{dR& zs>=LtkITI+1+FeUN}IpxeTn^k%bWp-Z63M#Q*UhpUSkn-Y(P08_hsbaP~ zCeh5^5z{UYyjcH@U(beEGj>#g`V;IzqDkR)qiz@X8?yNa1Tc}?RLDj97lzsWDQ>|? zUMh)>J^+l}e6*?nnbte$8(k|Nl+qS%?tjn56Sb?69oA zB@i6sUV3zFHopLtgApS@GKlzBy7__z{EM?LH@k!!=)>N3#y0E9za_tH1Ic7Q(GZ*4 z=0Bu|e_3)G?1kPfp3aZq-)MrY$y(7iU2wqD!BoWIO&WnveY| z-PSDDy+ifT!K$)3YWYatg9yh;8WN=l;;aHNndG-OXAraa39LlF@a2lRVJN!9=RgrA+Y#pOTjn7H6 zd)#)*4G43%!?w7zct2SciFO|zy`TDS*Nka9*Bd?qtyN(WAZy=WbP)dAt%g!=q`%Bg zBe0efK81kRCPQrLkg*h3Aw;I0(S9+;+bI^b}sz z@hS$qTdqh{bFcfaRVcsmt9oQZ2vv%`-iS`ve$t_=g0{^l4c+73y)}Hk2w{=5u}b{L z)N$XByBSPE@6ahP5BmdAqcBK_A@Rc1t%WFx9zkmLS`pY2LBbdqfIvqA2P4n&zny1p zWk3^Y9<5qf1EGCXKN02xy`{LkZuk|hShHYwXSn4j4P^$P%%WiJ&yt~*{2?Clfd16u zk&tY?T{|-&pxc*Jd8;`jlA=op#MWsb@Bs4}^S|A!iUWlDKHcopWO;N3DI*(XStW0V zMSi(YU+4P0)vZ z@4a2%?V7~%Q|u80*Bp8j|I&K_X1Uph?E0DuPxvb>x?tt^*pTuiUUU54K^Gc<0x7;= zM&SI(h$*tnayFQ$KBz}TQt<0DALSwK4iUMcox#>Ix9=zmOwxwB_x|1lpD4VwMTWm9 zhHU=l@YhcUgkgHL0XlM4$%6WUKvIH0i}kq`u~eEa2c1+NDL$mo$p%YzW6(*=QW7b1q{IH*)&IGWys}CpGcG(GDpYFq zyO;eO5OMLZ=PNH6Iw@*P@P`g8PlgGDC^6sLe|rm+>~tTNUkp{G!ZFuS(N!lGBs=+P zBoaDVIAq;$_dX{1clw)P_?xG>1!K!oD#h(XYBi2aA?AVAOUGgPNy8uNQR~i6D|99_ z#w|`(cpx3RBhAPK7VhlVS~~J8N4~%PYm2GM|772~x} z4)$F{egJhQ{nxn3S-sRM8DyU5mC2O%uH*d{5-YWankzAjI*|GphU!XJ7gNZgaBbuj z|3vV}4mMuFnS-W!yE{NBgnlP*jGs^v6GPoDF$PTZ1YMd5BcADjZGnF8yr(Sv93lNA z&%7LJmUDd}KZ9lpD-fRfS8ul2ro{a>lQ1li|-%5nTVPRupi{ zP40_n7FCCWRRquH;02a%!CdriRSHCWwUMut$>+n@SM>|m5Qg_-gy&tSmgIl9LeNi! z3cm0|Q55uvnYou?Wj_2!+|*TAg&uhI{c`MpE+|&7TNx(q0m`SI11I5E2BMwK(rxR| z85tv!aSCI2?7Gx;vMh~)l@!QNnwCVrmV2~k+eG_c@1p-Rwz9EMLZjMzY5t0x1X39O zHzNJnUCkd~1XWsQ1Pg>jdg%iz8kTe1?CLi>&CZx$ui-95lv;t2xF0SLi6UCSl}>u< z8RTrr%lR*darsGk{P{OXcNT&KRH##{1Ku#ju*6>f@96)i7IF;QM&_5%3zz?go=n~_ zzrT-!nfat`Y%*;yTUfb<{lNe&2x|JpCZePVq0<2Rw@Y2Q@5@FUtYs z%dac3AR+U>+y}W)TP)+0v+JE z!2y4&ab{mO{{@(aJm|#b0KJgM*VP=ZvLVCg^}6r>A|xta>A1;)1ph4nqO z34O~$^2)>5`85HkroV2gW1OlcjkLjYv1NJZ1-@UrqBmKE)yMm2onOY% zrj;j1=7HIhTvr2c*c<_MC2FwZu<>_ZuYebtg7es9H~{ld(Sgmim)+9C<$3#-g)ZCT zLxD6-0!wUs`PwVk7A_%a;sdgg50_DG?l-^Yb4W*>137xk@vXYBc}6hNFs%ipO=)iL zN%H7;C9BG{_a{^j^Ti(;^dKO7vX^r{Go%|bDkO-|Ug}@CY4~qpdGDRqbXq`7Tpk6$ zpr(=Zr7G-M9@SmJ0x9cfMcw;AKwme^Dm!XyC5Y(0dB6iwew*XNt7i3y24{T)-;E>2 z(R=_lkG5-pdO)R8llS56yOV}9 z@gGtpbf(?fD@!vil+NQyL#QLddK`UA#ENI|6ENUB_X0*rJ6^%LDZ$(L)so)j`Rgl? z@SnGcOQJ5vC=8W;D734b)DX*1ljZR5p&If&kpt2-YFT;pVU#7aQb5?Dl3%g3XPy2M z-{;1Uw=}j-Djl!81bRx1(x7vA**e&fkkQmv*h!|Xx+MQOmkXe-k@^qcc9;@l1tihf zzURI~MrcfuKs;cgQyXkIWMQfc|0d|+MEs8!&{R25i^kK#cx5UM^()mxz4-8S!(avJ zn;%`qe{L<`cY-!}#+KYP_5H2#Pq~dp0Pp#bahL69d)H6TR`4pzlauGm(YQPTR852ncsK?B@X1Q~AO@R9c zfX&jGr^JLrl$&Pp`K1G;XKp$&2&rf=yJr}{D(z)Qm5$NMKbButGVye95QIg?&FCRO3P-$H=&4nv#>FlFE!Ta$5MZ|10!%9 z4$7qTB?7Dy@gnbe1Del~S>#s}PTJ`udX+D>OkGm>k9zr2@ug~xuu`QTW6YGVt=JsV zIkt~3r+ar*-*b!I{{%3otf&bfUZh?mrUfI*xo||f)XX2Zaz7i(Sxw2VZ2%nm)*S3~ z^+CG+^;uL#A)5j^(J(i08LX!via$Z@d{c6*HlQ6O#)#7w(YCI&^w%#bLr@yyn=HMi zOps7^S%iLuk7KGr7GZqIlD0YcUy{+28VweiWT3f{AG+&rcW~tes!9GLQne>V_|hW3 zr?gRUzJ&D4xc{6}3RVTRsSNv-TQrDtuO~b-YyMshr!Q3{PaTxpS`Vsy{G%RLH<`#E z3E$E8I?fyD&-8dl@$AkM^a?Oh7QGV>;YU;+){+vYZa`%awvJvnvngl0hl>m=*)cX| zPRob29mX5ck&KgV&HJ>C)_kUfDGbRb4~yuSJof8|O04>%#a)yh1>HIwl?85&+2YXK z9FiNPk@LlQ%ZAvf;-39SaZTywaDKd={fru1qA+-Dh@?s9oQMS&v#fech zunv#447c}e=5aaPnL?y}aV5)>7E$RP?B=w4|LTiOVPnb|ur$Yx1PnUCAGdxHoK;u1 zUnwa+*94J`vU6%*t~FDRO3GIDMJH&ov^t@7jWsxn;#+KC=ZrjAvk{0SJcw= zDu}J2{yq$rzTYHKzeyB0^Iu?t22S*mMh33)ti2m`S@OJ`9u{PkC)?@wrSq3kWs1(0 zlmzi7-qK>~DJLD>uJmUQ?SOX7|Dn(7t~viG;eWtL^?y-(xC+S^_%uT`BQWGyF{N4* z9H-kMvF!XsG}*Nl<-UXu1nwsm{(d9cMQV2Uj7WeF&JmU5F^=ODdceE5a0GzqDrkR^ z4WIgN{7S*p{|XNOht2+DpTInLz=Zd$B1n=teA;CHoJpJ@(6z^IUW8kbt-MmWr>^g+ z$Q&vLbE4Jd(zWCHWCmcFEZ))aa3z|$thPSLq@T_Sc&tSk`Y#UuN6q*j<0NHO0TB*WBFSMRWO$VXD%UiFp(8jd&_m}7VRjK}RZaY}ZU$eKYv7TBQ$ZkauKG=PR2o=<5P|>1-}w&`_k&ij@W)gIDX`U)FwZbj4Ku> zyQatfpD!4I_5SP2%PU;KC%OhZ$Hx$<|7BAG-X7S$pbGtrE?u}#eI!5Xz+x}X6pe3G z9^Ixc|3d)ks$EOPv0Vb*co-vcGd$?)w4~x;JQkf9c0BT6qcW?jE|hO~o^{-Mw)PL^iV)O0Ine3UXK+!kAA0WL z|Dm036q@f0Qb{q7;EcQGS1r=)cuA~G_j2LDdu&N=nqg)Obco4LBoi}QMV>^@Z6CN( zoR0lfO}1|F4`C36l6CZ8O!uniP~2!&sh4pSKG!??j$gQ|Q!buhOp%*Gg?`A5DX2JQ zF@`k+uYe-I_Pm*JEsv~a!-a_t-!EV z_Ph31Az(`2w>Do^3|Z3ama4)@?mg{WvPrTrr}*t=?AzhsJnYCyG?u(DWmxcsb&{+E zMU|j;a7ow`tIbyuuUcJzaB2}V;vFFs|}xh z3aL2N|1Xhy!bXYE3HK->Yb#1IRJ9<8vNC2?M@WJ5UQ3_yN78Bv-G1OTj(C)P15+aW zK(8u?4|>Hs#*K`IfujEI*IN|PC&W4GCJCP5ZWsi9`HVUWc3Eu%pZYK6!K+hk%n{No zFUO#&IJ!}Q{Vt#9XPC40+e3ToxN7dEVscR6M}ex&Z1mKb`>zj3!Y9Fmw_PwL(-cWV zl~v3UKYlam(JT-a-%U&shq3YfD~Tke$_XMyl#?be^C74yuEij^Kdiq-B}c4z7@(pD zd>cZLq5e|`Jyu^|J-L9ZlzAeA@A*mo*@guI`Udc$AvCcL??Y&%f8$Ml?Rq`BZqE0{ zQY4r$X8?@>@j%bj6RpIPW9?2J#^n&B!DPdb_@f~17`@-k;MEL%-aWmOVA3yYY|tBP5U^%@1)s!26`okM($)oEE;Bd>e1dZ|rGVZu$WWKuHhamzUyd#+C;x-0F(|w>3t%vUbol1Jw?HS+%--v{u zTna0I2r*YcTXUG5oVb%e>N5id%Auj}xC$nQ^1Puui#ZtA7hcKUG+xoRmTDHY5!ZS# zz2gL$sM^raxXoQ9N?#KL2Bt&)1gAE79ky$RWBv+`B~u`F}20EPkA#vIb34G#m#y0tB4%9e*Shk^+9GqAyTJ?r~W>VyIG?)xp9*!uF zNidWjla!ei2neW*g*|xSt7rI>3p?+N2_!ANVv69VNL9l?JO}>K$4X~yZA3wVfG;}s zE3q%F-Jc{47mIQ<-r!*y=<=NO`&V(uxvW?^6>M4W)ZzO6)2+7H>Eic(JV5j|y%|UKdIWY3>}+OYaGDxwKUItS5h6H7pQyZGC#$fs!OAuzR=Don|^q7 zAd&Yz&8${3q1{r_zCkF=KZQ7KQ&sIbDPF9SL5LqG<#@rz`2kT2;-a` zvoz&*WT7M(pkj%&DEd?nw~^Qm39T2Ki*~Mo%3!0 z+C`eUS@SI#P)lC{y4UM(Xc-k83Aodu`-NayY+cM8zE&V0RJK&oZ=+Z#@=#J9vcy1` z`r!D-)p_9$$IU5;9p)B#f!7@pe<6arG~OhhwPy!BZ5oZ&{j0GPvKa znUjI};*$6%smG>4;|{r{3O(OB|rDV z+aAzfCmFf#B9PkN%{G1GX!REdzR<>|1G!c?+2c;&1lDzoUV`$r=F830>xd4D80t#9Bq)5 z2j(d@CX3l_j%H0a7@%wL$o)~p5FBCY_o8hu^A+W6z}KnGbVnGU?| zFnX9RigJ33s*H3!UqB{6p(suEiq!c!jBj^Q6#SCgd(>%WZq2xx{;lZm=h}SovrPmf zXdso^FUgvS$+LiDrpK^A^0pznQemRT-`g2D0EHS;iE*>XI8IW1D~~Uh>Sg?tPq#bfvRS6c?A7Tl zP7tl$&DgFgk+@|)G0kZB#d1ophYz7Gd`L*VyMc{PRa$b>BE2p81S2Vf{`prP&fV~7 zmD1X4TDOO2y_&s4rhj&K5p8tZ8u(fp2q**y=gL_`Xoo}hi1FV0yDcRluQ1UM6T0l& z4ch<5;%>K?l>^@D-4^Q=Z-e%mXemATm}Jg=g)jkGKZ-q=I-WKp+U*UOM@r4o*IbXi z@w8%Kiy||lzdO7qjC%qCRdU4y@ankf9RtDm#&}yp@U;dWjHd(vkO`)=tY_ zE6G&lqRX~Pm`qim2=}i3ahp>B%JF&R^l>Y8haAqD*=23`3}DdxJNN5_0%l0-kLmj| zzbF$;gOIbW$agS~q|#{~uyp$+_W^?leS%<}(MP!NlDf9_q~NKoT}BN~)(nzomhq=s zWCP9+$boz(lSbZ@e5xxPnAuQybqR>+UU2?D9hB1$jbD+-9EOajsiReFgp(O$8*$q? z=T74K`i0bQN{NIwh6laStH(?om{xtDmFR!5&frlsb8$o~zPw?MikS7Rg#3C_VmYoxRyx0bCQ8_-ABNNC7 zmVcx+$)Hx7E3RqrEx5LW(Vl22EucVT2n~bOoB;+FoJjIvwVBjWe(?BR<#12D2J3DK zAho92v`AeIG%-&{Cc;sN)&7}y`^*Q~4ITY!cf=Z|aVSiQx95tEhoC_SF|8DD;RA4uG@5TDa=R%a!zP(OZSDKJFYRsO0?k4y}q(VX5 zI53P?hTGC_G*Ohb!#80Qi$Dw<!0QHUF6xf6R?ATvmXlI07!;*oaHPUkc39ng33KWujuItDdvaQ7-OqaHwcK1h;07&EX2u2RQyxXKWEeeD@|2^Yw)Xt%g{E8slF7jNSj6q&jjm<>oZvf zQui?d9p^iDXbG#QoX;j1$-o1F5Fy&fzbx6wQv(ryT#z0+c+7rbtQSjN=Itk*Nr@q@COWi{%e_qEDFY-7MaPiLKq+tMuC5 zvBE1uRy~9Z4e7_!3}*|D-vuY1eyvpe`HL`$&$!6#4jd$Xo6R@**yfBLhq%c^Zf;;eR z*;V!elJT=x2%1iHHIe?)%n{RE*YQDFFg3Kme9upW^UyHF7n-XTL=jA=ar$u9g~~Ja z4pTXF zMJcRKcaD4#~)RJB=MW>>O{#XYhLffwFZj$sj!hi87sm zsDdk`^nq^=F_F&D!*_`zq6UtqD7+?V%9hL0Fc#*>rQ`MFq6Lk)2K0{7b zJx77o6~(OAB5jqorqOv%qIFaMdm`Zl=m)+CuoWRmS*qEBxL;;HoUmWS3P_!&Wj{m(kX-a@-5#&IlXU_@v-C%Shxibhjr<}QWh(WQJ zMIW;#`%?Ww;BkiPB{7h*ZbP>wwgFxmIm=NEAWLH5#@9s3@!KibR+4YgxBY0McRnC8 z)aOPj>x!O-)%kc9Ohqxm-XAW3UeE#j5-w{N`k6A$r=^A9_sdmesUJo*baOh?qnw1r zLS*`YF?98@j?V1gleS-U=!RrKNky5+Cda(?IBiQ+Nvcs|q`K-BY^t@6>@{*X8i=ewgF)WYC+N zcrlz_mn#T8ed)MRBlMcRL_2s(FuQ8mj*FM*Pe4iNXNv@c1D8E(_j!L=LrTir^ERVUybSC+Qjj9ad{=V3^Q@IDdiL zg$M;U!sX%!{1#5!ad*Yx;pdKF^0%nr$bASNW?Dw31ZmlN8nn%aL_j#;)@V)wUWe+V zRb*rbkGX*zQooo|^0=N>MQqxyY7}tdMfWR{cUDaOYZrjV@R%3POo?vV*8w4O)gI>@ zsNX#1Z~v;3wBg&$otTX1cr*)x`M@vA|0fYyvzXOLV3&7)L1^NAjC#E18Lrv_`>k1Y zTT(32gf+E60rIN33DXWXK0v3*y_#x+_M+mZrxy`?qW3Z5w}wo+h2E0%+jmqvX!(MB zlFUF3%gQ$vIr=)f!yr09=i4Yb2t{fN19JEKuVTCJ5tZsQeu<+fWu2d@Zhrz~vMQ2x z_N#{-zcr&#YqYCrm6!CjBGobUU*q+PiWIHF>qc8cXr{VonxGMyaXIcchMF??0pI?ds zJ=AQzZSd0uL$0^|sBs4cLsZd-Bw9mAFx{2qxho!+QLOAM5p^M*YrzKY@B-!DvV9l} ze}fO|48rE8vouX@?nlV#mq0#a?OT*aFJq$P96v>V#teL0yDs_A$V(w}1tvxSj~j#b zEh$B?(h)*ys|a~XA$uIE7?uV|1Zc|3Tgt9=eMYx}%_Xd{SHkf`0x>=|4XktzJVyEk zzy}y=Ploj7MA(X+bCG+%e_BpS6^1zu@XCgwN}{4CW}Kuzhc`6hhN$x_pq3G(Ar!(r zq7Ok?y9JXS$6_F^H&a}RP+yqrf15f%( zPE&8yiU=0j5v8H%Qq^gScwbWT&(LbQ{kvwkG(2}tGs=wg=4XVIIbvqRGf5|B4Ab*_ z9XTE-ChvY#Z)Fs_5V81v5etr>X@je7h?chP#aFMwfmJ&@I;+oxo*`8l& z1JxAfLrY^S0AC3?XAEgEthTA66rY;^VPxbQ!&yx1*Y#a*7VacTBtJ}kmlql;amv@v;Ez9sySFb+c|(R38kApH zK|ts&=47+CQ`ZtumjMMk9RAy2!8Fq|vgc206h=&0ALC`H)gKErZWf@Qiksq(SL>9@ z=M{wc@3oL#Qf8NVCld-XWKS-!33eWJoVQc;WHvM)Q`HL}5tM9;@`)Ncbk)PadFuLY zGVqgBYF{TzG4>-iWf2wDaJ}zJOqxaM&*(YdZ+JVi9!ouAIc?`$+Mfkw4@QaK+`R~? zGG`wZVXBOfE`(>iVNIf4eLP^!2IDN2Oc^R4HHb&WmqSs&;@Cu2bz;x3@obCYAFbrs z6r8IOH13S0V-vqqa<87VtCNbXX5`wlOza1EyW$;N2^6q;&>vSAZr0n{L!hNOM{0@ca`2)&cl=ZXXwD{aVNocu`mp7 z!KfI5Y7!c64Y9gQ?$D!IqEX_KTo9Wha-{W0M{>?(&D4ji!);9DJ;n;%!~+H@aX6G* zti9M$TCuQjaVDGuy#ZJ4lmuQ_3lYgkkGFm4w>O)~?z5-PB!t;9KtQMbDkEfI_^@i1jlm;1^SIpgYTo z{O>DCvn%@Ngd8MsU}$RZmPHG@M})55YabWrgtUK#?8CU^uuwR?zSi&*E6UnW@F||C z41d+ z&9FiIZrzbgeOkj4vV;Z#Dw;A!aY%`xa2%{^7U(p;(`bnv zY`f0kB$(nj;ac(Fo!T%ftr=g=d~V5!4~H3@lA94>^xfI7@!0$)?E5;0T6#n|c$)dg z7Ya*J$>OmyK4@kt#g05zf$InnW>(t{^D!x?H=n!rFU*S2rbur?sq;+fEKGP?c#S=V zK(2kz;57U8%}na-k0TPh6TQi8>s*g(@g6iz6PioL_{g4%3UEW0l%NOqC`2x)Q*9ld z4!>$E`k~bwtf>$Q>;$0=Wts~!PFlJK@PlOXMA{;25ixor2GkLL`PoYdQ82lAJu8@_ zMaZrfs0u_;*+xOJQalG%5kU|vUVO(aj}EGH=DMHt=e_~-r+5OsdIRhq;|b5Vxalp` z@^cn|upT8(({1%F@S?9=#C3;uc9jrd9dubCXzcoIzFF(v-LG|SMuLL$xS-ev`M8~( z44Sa>yI1pz_ila|X4(-&tl>Xa%82drWm6A|t3WA+p;c}v{8ntT-0jTf+6R^w6-{c8 z3u-Qmf5vOQ6_B7lQZ3J2T{(`r(xkt9S)XOoFMQ8LwLJf_x9(Sp`T}X9qXMkGKn8|2 z(qhAnS>m%M+*U9pj~Va`Epu?{4Kb-8%d)uo6Bg`SsFEtY^LuYu!A|*LV1V-q0vFs zGH$IjSZP9g$v$Z*~qCyNV^#QXc1v(4^egHFfFQp_C__&@FwVH1u2chI8ltiF@)8Ut|>9zx0 zcx4u;*(=7hD zz~{Q9m7in7X641ce|5STag3bkoIl~P+2}5yH>7JLGz^TyUS0ceMcYsrskRmf?5^6l zyCjMQyb-Lkn?6p!>YSnS2Y1IW_^vQ7MzDFcr?$S!6fzfw_{;3JVk8eX72D|Y*P!8^CBqn zu%}PwnyJy;95#sU%(l zqo5?n-y&d7gBT3Blz1Z8AqrSCOl9umufq9}4BnpPu`5bKWRDi1_qHf4BLH;*KA82q zaIZxAtiqk(?~Xq#XK}qJw>9XK=MWcal)Ovz4r9@ge1^_BM@Rx#xe|7u`aKk^C>5^ z4vUjA%NueHp$4)Gt7ux|et*u{1!RI5d)HJMOxGbsH$C&b!bnSsK7>0ns*QD!FzjpI z@P)YkttI*QQ`P8hbUcRJ8brV;5+_z0;fGekR`O)NKEqB1yw>B(y!wzAWr}zMJqKP| z==qNti+jFx z6<3v?xhImpkc>tinw%%-z);(Chd($9U7b5f&HOC|!dM(^bw^!pXuIHEK&F;-K{qK!!1K`Yb zyIJyDE;VonP=Y zYVC#)7H;>P(_kw1zTrU`qJOev!inM&yXt>g`idG0181@~+X2g6!4>3bJzkX_$Rw3A zT*K&lY(Q1;R0M=r27RAH3XF(S4gr4|bs725(~$?Bx;IfuT8$K+m~$*?xFmQ;4EX0W z&tm<~=G8Yt;xPdp9%V%Mig2m4t5TOk?e09aVs@V`U1B=cfTwCc%UcdS!} zp3=W7P}$8*RG4l9x%|VZ9Ci{96uN~#(d;N6Zs9A2vaVOWRxx_{I0zxv&<>La0=aQE z$dlYuZ5AS94P~Cr7e5OG@0D|OG~nIjB#aUW4=JBLH5Pq$cMjz{@7b(-E$)H!Y7|A< zq&C+4miYTJ#vL7b5o> zbyp|e{Iz_pIN2ydxHx;B*2_K4cxd)x1qSmt1c5AP!Z?1WmJToxgPee`0UyLy3x;Z_ z$@O?lEZ{yyA-8Yp(Oj7(C6bLL3IAvzD&wMHy48hf9wjh7;K@%>(s8ddlVTE6UYoU5 z+~ll@VP2oeP34AI*YRnuCaz-;KG5YGa!M_edqsgj{7d^w91I6tRlaqc_gOW?#q@If zMTl&)3oVoP*;-7N)z;s+nWvKF`(g}QS^2m}O=T1yjJJl-zpa^qH&&ai>Kgpf@SuT5 zZbvPwQY)E$R7iA!Q^`sb!US1n401b3Mg08or{Ih0v9p)2mV5NOjUq8d_r#4Dm*EfFsoY_PIdY?>hTBa z=VGv?%A6)82ud>2`ORd1WwxS|UV`N0qK^KUQP^8}$Lt`Tv)D&}S!}MzkzR0ATZI-i zW~^5`@FU|*A7!)xr-nfGQ?%RJ5VkXnd(ofSq4)-UEvk^~Fg&Pj&?d198}He+V58Uz z;_(xjmT&c=gV-;FMxXjOu*)TAS?H^V5(U%jSFg7DuQ!2`KD!b=*#$F}ZgYO-&v2Xw z{aCsXZTdkFqP5&R&MJXv`f9M(xFV3O2yldo-*~=}kUkr>ZqO+fZ6{+fJt}T0u{xWI zVOp%+lqpm625vj83$QUWtnQ&10jwsI1Y&08*m!7rBjN`CcKK>@&=^t_}j|93 z|ND6sLuP>kIycwT_7*7ORWrTi)*I)zXnA@`piwe%4CqpUPmF4%@b(6-VZybC8|;AM z%--qoV2-n)PanVgnT{}w?4{xvXU+;tOtCcMrSNpeNLRHs5-6Qlxx$Y&P2k$6gnK@{ zLp=sFSV}wblmt{J@o#U$Sof6gj+*alnP%u$9MT#_Sos@hHPng`ePcdy~I1L2};B$i+U^QU@3dX9q~>Z z$}tHI-Oqvy9x0s3H__4gFS_lp!4I|lJ!?BTkl90t%H?59h^yP4j&b0ac`!N=grW4I z8KO6c`rT8e6r`{Hir!K8LEbP2+3d*u6+^fbdC%7+u`#_RKBPZqFblja$v!@U>LddjXh(MAHj1LhRgi^SNE8|J z&m!CpXIsyP-;_RVMVWLCy^OGT%;WneP0ovk_5A(}3QZV9??m)ewIcFh=zxeTR?sD4 zRw?o{&G6!-58-{Bq~yI-3SA-A$={wX>`oYB{>@=ZWPAp`RYcpYdsnlehJAzqp0Jjd zOjy2Bz7w%%l-WkM7r@zua}KUkMfa%u+7?CyF@Q^_O!mMdh$`Sbt5Z8AB|1^#un~LT z<$DqmyVD+E59--XYj$X7@aPk---tF5t1{k^_7FsfM+P*Y7BRs31j|gJ{g4GabdjuP z@N_*0jg%r~3ydK4t~opeWbv*u`T@T`MPG--U6ne_p2@)QqAplRAhg840_3HqBL?Ze z9xDYEO5hdNkpRFWwZ7|}mn0V3j5k6)wU?Ol86vkZ7y+dk z5MiTMSva4lJB{a&(8tOfwK`VB2ngs9h}D)tB}d`jov4hoTS}+JR@U2BMfFFx&E1l&9nr|6{~UiM?+Im5H>&5Tap6B!nokT) zN4BzxD(uvSF>?l2yeN(IjLs_!=7Lqg3{H>Dj!dDH@9)Jzx#&SqT<3kj&WFGEE5a2* z=~ClnR)HDOMc=0+Z*s)lS!bo2*A~vyMn^J>p zzQMQ&X9E>lBNsx@Uh;c#^gc05XjLv1#gL4uP#fOD;}j>}r`a~=Zr)AR5#r9|6K9t* z#f`Eyikq(ybUU!s5M(KP8xYAsn+?-H zy#Do&I|+;MNm2kUxI;LVJCyKU zf~T%bZi};$Gbljb*Hh>2zW_+KnTIB)3#JzMFC^0fDt{e8NL9;yJG!H4o0Ek@uswy> zeqe*+Q$!6V@5@i`2qAZ??}lU^O>ZgBEEDn(Q}$ZV9;Hm@M|RMmQ(_khE!=-BDfHs7 zEqnk;ea}H4f4-m(fLBxdOlhvi93vh7W)v4~KzYn_8Hb{CADi_Eb$TQ^X6$%gg!{V! zZM3x!S#ur0eTX|YD*qUa>fRvh39m6g|KrR{^HXi{ZE05wv6nEG&?(pEgYv7vBcv`q zjIVXb$e=WZXj!=k*KQ2?K`eWxSA;b$tsmX$34{V=m(E~l3=21)lYC<%1mUe7%@S`V zq#da8z(sFBz|ev|k_7x|oe!a>M1mP?jOQcU8XOJ0uKv?&AyZ+gzM?P@y9a@H<*qJD`rQ4`*4SF6&4kBd);C%I84@myv4!O2gA=PQg2P@h+*CyD9*j(dS z2QgM{>}OYPh{*SlVTV(PK*;BJ%?4s`g>+O3m|SQH&3mj4>n|i^sb%z8o_8x;!EiS_ z9tkmqs7z73zElMQ3S84P{iKm=Gx*ZbM6Hq5|A9SCj&-rt`y6-o3vKnuMOoJ23n&u%u9>DZzVX-6Q&G5z6u*X0Xp zXV7GTRMWZfZ=PRfrAElL`vhH|-*K9~F{cKu2Ph`^BgT6gIN@jukkGVE8{xL@SIb9> z*OhBB3^W zfP@4!CKTeVo*U%Vnyc1^c1o_DwN#YIQy~b~s?%#Tj#g?S6e4zQ0sWHYi@V#vpAWqy z$sK+o!%b)lIJ}xTq3CZZLGZnc~?##PeZL^T=j73m| z+D84V7dRpfdBm>md1n;*V0^ToSX@$s@G}Tc?Tc;?**mG>iZFy(1N4079M)|L79kr| z%qDVXS7?|=c&Hnr=AKr}UTaTHMNHW5Q%e0!EA=$5(8F9HfsWgS8LZhph+I#^cjNoa z^}znWj5+&c>v(tsI^CFk{qk%6#LMYr7QvvID_i(_hc*ckueT{8jkBQE5^@G7 z%KeaP_dswxA}g-(wQp2TOnDBny6)2?XuV>6{JZMekPxxj@Lgq~$sx2>Z)ON=vRm>| z1+}K&6MT!B{1p#fMar+$V9&GxB;$;AG$UoK<1Jh%A+=@ATSp?VE-WET?(8*!WCl>L z!aT!)YRW%y@&I@}lJWp8kmD%6#33LQVgoU={jtY_yiPNJVHjf7v`n{P9z}-huTsoI zMEG@!`cbZ}rmZx0%M7glx04)a<7lYZEA#;9#W<3CjhZxDOtcB(>%Qp8gN$~Sq;`h) z&;k}3S+J5}(|QqJMqX759ANW@O#sYGde<#A-ZkyFq94aF>JoT<$?eDEs(yBt7{6zg zPj*Ze%<(Uq#>BiSf}(Ia#*@MXeu*w*(1$G*R9H71O7FIMADIVissvh<|7}BQ>IaOm zITR%RO9~&|=SWBJ@M*9Kkg;@c{*sqQa|T6C;do}TqgUpQsSiA(RMEE1*sj6jqJ5%- zJeIt3wbb@JXJ{E zTP>=!zoddAjlcd`ad;*S1wL)}6mA#>0fc{~I?L%UqxN@2WVgV)=q)H@MsA59+1s38 z(Wp@ObbyUTj5vyDa5oznhG{QH>3cQYd1#+AGEc5Qj^@8DiUos%@NQN@(}P-jn6y5NKHGV?VYJtYgAl>*SDWz!znrp!5d zK^kyt1{TYHU$$MOT$kWwoY74lo{5V`htOTB4>tx6xaUet($ep>;%eE5m5Z}(SF_s* zI}hefqL?29kAl`31TE7vt{xJ?);V#i4kYtoF^)`gn-g0B{;q*+@!rufI5rjz1Fd3Razj^g${b6bw!x-0p6LQ7yv~s zhGj&?$qhzSKV*Epfhq51>vq8a_J4)UkmJy(G8$H1$^tV13##IB52+`r+}l>qeT%uZ zz4`tV1%l4_fTlX((F>@X;O7hW0qduL!o6@wv*hwN#j=_faw?3v{C)_KhG;~OgEE`V z^@~}voh-Lh;7GX&2Rjoiu5g%UIU_)4)`9^6{XG!*fh`VOb{NQn$I`(^SGv5)y)0T8 z&*J)^=aV|2c{RpNRbcj0B``Pz7eGPr|K8cQn2>vzxB+9V&8two%J}atr{TT}nOe5v z)+legHnh;H+REN>ml^jV3cWku`$L=QFnmN7X7-SIlaGq_WO%LKm)cXjG^O0p9(6=n z8j_2)U0ZZZ#&7Dm9`PGrQ?}Y|9jA#6a;8%txt7v3RdFo-Tg}a(sIMJn^BuH7zuy>H zc=HBk#Q zoQz-tl7ajdcK|T^`xcj~{7lY@xjk!1j&hsgXafkwF&%=gm zk^62egc8_1h7~FdHKy=MjBF(l?ijWcTqfgo;jC z&qi7kZJ22OaVOos&rrM=S|X+!mk1gS)dvn5{*K%_@QYSqb`@_#2$LJ<9bd55Dp}T#bjh)h*h);< zKgS}1#DGGaR@$eZDERha#{sWidySL>I*P}OSinU{X=LV8yH*6;Nw$_Jw}%;lpG$8H zjuC8tb*W^|DIO0{sw>90S(9Z&%YQk-txTNs!+h#bN#^m_=%Y#=Lo%Flc(R@Uh6TwV z($lq{*I(ocM&nOdJMf%Jp$06%+Zu$qok6g5%bV8<+Hf(o*-easTsHlT`JRT3i`iUY zQqH;fkoaaH-VvCM+k3Xp9FTTiB;;E4XJYQjZWy!VL$KauC0Ts0{v0+Vj0V1F7BPQTTT?Dv&pVwY)kV{tDc^~0Z>BeB{wD> z6U=mr>vAPy5X{6hMVS-}yR4^21pUCm9#%-`D}=Eq31ub>zAe}jVD*x7&yMlZgb*0bz&~j z7oa{8j8W*wLV^4uzoNvgJD?Tk41RyM#fOZsrUS3Q(T?Vh;`)CSzj#M|_{#5;3(_HP((t}?PirEZyeUfLrK!;ouS2fir`k0n8M)=< z=X-D&g4Fu8k9!k;$qI3UE&wW`_IsW&-}Z~2q%?&~dLtk9c;*}FWwVyZNe=iCQU%UA zf`Sr&_?_VFyEOLnwl-GOgBtqrIA1l)<|9e*TGY#e6l+>7gCtw!Me6bm*(Y3Iam!CX z4O>Re!leROQRT`Y)3DV&l3i6+(cXu;&tjkX4S)nne+tubR8FSFOh0}8y<~E7ES8cK zGyY3%w@jv>)XU-K*8TJ@rp_3qjWcA}Da=f&8SauTaeIOi8$q_{j@ZuqVG@#+rkAZW z22TXT4Y&D{X8V1Jv7R!s;P%Re%{PSBuKi%@@MC|W^JgFGK^ZgJVqfG33GY z!;~Y=91~^?AG$y^1`zW{Dv978)HVu4`(PY2Grhi7$I9|xZq@;&BUsH|B>GO*7qeh? zHg$g;>mvhrI)JRvL2UiQ!d~lJ;ragfU1=XMR_CsEyJ>zHV&TmZWUG06=gZ|juvKG| z(bNkLta{hpm+a;}qqX4W^SeC{tgjn!QWPYEUc+bzzmg2niCnu)$?|GcQV{;6;mn&l z3Y+(&L=Nrlzo%~Ly|KP?m%nDM&g+-N0+UY;m;AFEMY+Dd9|87Rkh9P4$^R6y`wsNb z50xWrMT##}HVZ-j4wql>uY-*v8+q43VC*0(tY?H{wRREui~OFhtDa%Q^gf*`Sf0Cl z(@uZfXlRxs&Hb1=k{;RC6^Vc+` z1;`!oofJhLIe<`wnRr`s`nejtc?52#!R(bkqk+W4?!9G>S86=kDcpbVmUd6#*0j6| zXb+~+xiOJ8V^T#*I-p+{%Bi5>1Je6TO>up7hT+{xt`F~YlJH}4fDKHH1q>N==LbDb zvSY7?AB;Esh|F*rR^X3gCGN<6H4AohRYGe+hl8RB=@BAvLizl7X;?ul5*Ng87Lrb| z{RA4XnP8~BjC7VZckeN#GB`sDt^=t8R8;G6e5Xtg4K$K@+snZ3BC#WY!!jpRxh0jL zJ@e4}FF$-u3Ep1oz0XC~&y`!WkPF#94GKSiawf6UtF}x3-OPbq(5f+nqsh>=^Ma>s z&do;yzXK1(Fa8JRP1ckQE4e4yV^FmEp+bZzypZP0pS=!Rbt7ZRs>D;sMLeHaT{^eVFY^lw$8t9*ZSr$#{9s+FXm9*iDFQ0 zPre%%uuLS{1gzhH$RD6!Z_5A}Dd2H>yxI9!_j-5~BocMlyF2>?WG#)_dYbFJ9je24 zM4h_6OSMnnV!nONF@PX2J*!3^x8(-M#*?;~mE3%De-2do-er^)_7qxxN^5 zzKMp^pa#-jcs<&k!j0hte<-fb}bt+=%i^Pu8K$=_m9{kUJPJ zW3l&_%y%#c0ye{9?A$wCDVa&@+vNbly5Vj$?T{$e>tCln4`5((L#&AIu0FH$%MJMn z4`j=%c^W85(~DU&Ee*0M9vZx$8i?K)PX)IZc{dz&*T2i?-q%|cDEtlrWaN>fabfz` ze;uu2qpvB!X#Ns58;PHoAZ4KO>}Ts$I#bD%p7sME9{c|xw>S&b(N1*rWCgZ7%g1Lp z_jH~O_VxuS%h}q5>nEFpGHb&PUh%BvWih0q@QGV=DKc+qulodQk|o2t1%m4^z>$JB-@FCT{1uIe`qrTvxRMf}Sw@nyzQccOJG*}#juA`?kwFHl z_mS{|wn_i^H{xUy31`V`IKfmte7NdMm@evBm^=1VN=g53=afkK<1^1trzlGVp>||KS8ZlvclbWXZi3AyR^pl%S_$0{2(>|x#OzSMvOJ6d zdVBT$aN8n43mj|fNr%f1J7}YgYQHH46h^f?m8SZ6&||Te?Z9FRtCf9BU;L`jYs=OH zvbED|*V^+%@5B0i&5gK2U`aep`r@Gp%83s-(JLu#5>pmjLH)(u&W%6*OzG*@7apYw6Jsrgh61XCPH zny@IzTuW^y=C;{LZxc`tL|zuh-!OPZXt4HTzr`|Gwdo;7Oxc02VO6>G zFm(=RPs#tyo1N;o%vUVfMGyOllxeqL%>|}4HU{t|Vj05~@UIF}F}lVhK}qHDRv>lv z3fyV-IAP&Q{tn)5QPBZRD@trI)$;J}ovDLE1p@gmTl{H(ZMHs@AklTya&rM@6mqLR z1tAv**^i>ijTB{reOoc9Se7=DR2e2z*e(yN41aO5C{l^~$Fc10g^dLb{6yPIGud_- zP4NXm+%&x0&Q<(bLWcgd3(aU7t1j)u-yMom6*spdH5~NMuu!pEx#MKd$3R_D;P*FU zPh##fU|(TiY$jH!`qzk6&YCm5X264TwwP+f(2JaX?l(Spqf-u95GslofT9nBSS2g! zLUMG6Y}Zq~c6tapK+Aqu<+M8~gEVeG~?H7|`jl%L&&7TK{-#rq;P z#Xtb!O1=TcRZx>e91GG355KmoLP%yBnGx3sdneZCX0!FKn)+cQBqc>75<}$27k?vi zVHuNvF6Rxl^5QQEif>*$INmcM4*^Pt*sgyQq~V05@oZ&6G4;$-ypc&i1 zO*1UhR=d7Cu(kxg%!2%)!C=&C-%wej0Kziiw-7R+>+44^iSp`7%CmyzUIpBW!)8&^ zB{y^x`;de{#Fx8{ugkEcKF20|bk05WBAzyzF?CHzAj&>W|JD^3#bo_W5Df_)!tSj8 zTsFD6mc`4_FQ^zyPW1DxN|kXR1bz->cJKpGx3l%j8Q8i*1g8=}Fsic@0AA5QjaIbO z7Oy5igJbCMSKelilxYqGu&PZt`#qQ8k4j6LG*X!Lm0_F_fj~c=`6B<=pg4}-e|8VNw4_~o1%H_)%yudx z)=A^3G7krNaUfH@c-z{}3`&g1N>NZ-j)XeB=`@a`G33z3((<61g1{0 zA$4XVNh*vQ#_(plj6;63M{~S4>A!qpJzIrcoQujtvg{N zMujO*K-CZRC^~{f1c21`Qf)>$Q0EUekHH}zWccgR(%T!W(%t%kTG4ndUa7p6x8nJ? z=xzmE56e+n8|W3;B<_|?)IG@}&Jlrx6r562664|V7y%5*yD0K@pB!cT3qR8=U=3?9 zHJZqiSBpmP&#m0z>2t2%&Tp&%#XbViNa(O1Fe0jE*ebC}o6<~v*|L9sW?b+w@$Z}@ zoT4au2?yCQwr97;E8U`T>%8ICRJFy1FNO0bu0@CSm{nsJ)cvF;UPdh}Pq=ux%Q|-= z^rx?+B?WLR%ETQIpnnrOh|?FEj0?NP5YO@h%kQ)ARG3=` z{r5^hr9<7trayER0>E0mUzTXCJxLsIX;5t52%2*sqlQRF5Ae7CLS+Hrz*6;3d^!n$ zAs?HkhMut_;~&sej`_`sxp~lmLbHUljP~);JcxUdgGh-90~J5;n4Qbgt7jC2CA!X@ zg0=KdFmNZmUa=ul(v3DwA~r#j{Bt46JVjpjQ^J`iGA1WY5VTv938oErCm-w6k$)-r zgAwRH@e53g@bk#>AK{?ywCPJs*r3s{ch*7J*oW!_1}j26ICRhmpKnF%nyO|8Tc|v2 z6WPOOrS#Y{BF^2A2(hMv_{oRWDS^!|@u6NN0fI~XfX#RTJfd|Tx&254_5hHjqEHa^ z>Y0V$2^43vzKLPnuuk?knMdltfVt0p(8ozVuUp$I8=5;Y`71t70!tUt7N;FSryaBN z@Ym>B%UC6(#*(%+xTN3f0LzgdWGmsewo=JTk5%6>cptgl=*syr_+6bFNcC5O3UB?a zS1RRX>1njZGOwFxJk6B~9=}Cj!=};)`9v~omSj-cG-?uTgzjto!8)e*yUP2`* zV3ivY%Tdn)5dZ+dFFv+@++-E(&9P5L`utC~y)$0)3SL-}0cQ3>sRo{fVhyH*730+9 z#?VUFS>%7-H^ls@z)c~Cvl8F*YEjrUp-`txoXNPH1%T9f+!1k2X3FpN*SkiuY;vmf z^#-$nEf+l&hP;LOyEvS9jXv~WvNi_uxU3e$eKM!R<`V|?#=4b|DL-m6r)5BN0vuo! z)FwaI$a);?GLa<(cxAg1876N6^K8_>;d{^ZG0Y5-l1&tPZyRBSFl6o=`iA8s$KnGI z<<$C>F3Z5347oZOhI*34`GZ?(8upru>IOiA1!+PGHi!JmEBEg1-*=u|JmvU-rOL1R zWM+wsLQZ4>LhU-2lp*F+?Dz@hWSY34J)p~r(c64l*4Kiz^nL|Z`piLu8@oWz>5f{J zA3T!icx0CWoorZh_u5yLhp)8_{d=D)oNf4qjEeKgtYvle9mn^^T$XVyz%9A?L78&rEFVy8FR=A;n0?JgjdMaLEU+(-7NKuZXU#Fv@?2FPkJ{Nolk zGYLo7DaH7&WsEe$-0&b@BbWp74Yhd(ST&#BgL{ou6X1i_=70b^8b3>uRk)_z!=B%_ z&Fx!dW9-5+A!~CFM7bP;?*?5)O185Gb52%=AMaUNUEHued>WYM^SImW|-W~=bNvOOyK%B^??{n1JXfOn2;zn;XymfvdX=AJ3}XM7xJd+5b` zRgH`=r)vI6J&v@xN$FFzJ@JjfJS(98JgC2XBzVuk-V4b<#o+doDfUtKX6f47Tkr%z z&sUmljz%q$K}~Fyo*vjteUQ5hb@tq2Z?F}@iMCeO^XrPEm0lwx)L|9GHB z=Z6rekOu0KAGwV)0zr&Z*-gQl3k#^kxkfD!DCb0f6MRB<^YzI0I-2huOa zaqq<2#qV%~j?`leq!x7k>|E<5p-WqpIUO<0h^|(|FCn9g83Fh_5eNYT*c3#8u8P%} zAqTTs0_0(HNG~A6cZEwkJS>{Dctjsj-ST|Fz$nK6L|q`%ro?)1RjND zYyDO@SWxT>U*W)Tw|D%8*dess$=sG6YB7@Z?u+yxGJaKp0P=6{7)M&l>Wna`7 zpn~5&NM^P#Z)tpPdo-d}0uR#`!t~vesd5D^W+lhV)$siSz|#0FXCZ$dZl2}uahN0X&7fxQ)D}lMNH++s?TU{ zO9q?hZHk!8>Xa3P;38znxV)ntCFrs7)GB0;WeTsP}i*`1XcI>^#3w;?^ z>&DoM#I_xw>ZLA7efpe|2Vf>6+zGKI; zNz?{|Lr(b#Hg9EeY^P|z&ZwkcuRQcwB#C7?%fhezUqW=2=+rc!I)15D3zV zDp?m>2DoA;;75BdF{W}CWZ0$`(2Uiv?ArR|xd${|++?maZ3b^Iw5WFo2+H642f}55)B^52 zNEs-PTCJ^+!4Yd=;8|xId7j|h4>mb z#o!U~*yQrB1WCO1C9+B9zbQBGWi5Sr9-Oki`962AytFM91JtTzUZ}?_w9i zIj{f??>CM#!YK32q%xviIaatbil7YO)y69ZVsd@JcAJuzYe1n%BQa7s#J-2jRqTd3 z8@M8HulRrX-}yh1(TC0RhDIr8)N#+v$!GgmuzT&>&+{TFhtA{6g1y!}b#IAdNHI?) ziy{^v@IL(dwL33Y$*1S_FE2pbN7?;NZOmTiUK_xTDn|VEFz?x0M?N53ckgp7-Lgf^ zqnhBjsa#dF6;edFKXTh#;3#nwS&4|9TBWdi@F4oXpIJ1TA{uU{eXbe5+1EC!7hL$QqCd`;n95+Yoc+Dj7d2mJ z)c2{(7jheu&CUkFa!CMI$5*00`|Osk(&9`;-k`#vS7o{sR5T8Avbox6TKSExOMm(@ zYL*79?H}E?r2l8Xd-o5|{ZqPPs*!ti5j48n<+`XW4{o zbE+hXli%#QIKE~+CU;;I6CRi)_%aN`oK#ZklzYtFHN&99c%P~du=4O`k;vjODt89; zQpJFqK8X%xZM5l#B|_{#4^lAKE zbkn0V>eN%l`Ii&F2nuoNio{A45TN;saYqL{J_u*`d*yNCQwYEM2**sqf0}_Sl}%MU zFd@g7grgC~6@N52ftJsVL{nI|DFd){nY!m3D>3NAhvVPdo*L!W+mkTL8e0wLY?eV zR!#0a2<$4=?7eQmxlc%P_b#7*K#jaq!~z?o@3T}(uf=-L$24B}gnDt>e+(^Ql|Z`$ zH5ZCE7{ADW#{hrRnfdP|x4)=b=96$Cf4AOBSce=TY%;kW)5XZFc9RQ1MS=vZ+?1=RFx|nLJF#0v=-SOq*8D0w-n@inG6ef z<-RFun8CSTg)6`&)rQgA!9HRy&*Jg&-Pj-A7ANl0;n|L3krrm+n9m4qH(v%y9k`Rh z%aX&?7o=fcb1N5yoK5{mr|;Fj4!S*poW zC6#P9CQYIpr#XAG9%`IWbdRR(JDR>s#7o9y8P9SB(OB0=s!remx<=qnO3h_S$)vCh za2lGw)w|rBmzxTgTfGMVyq9*h{NkshR}%3G#;8OolM6d`N1&!dN96=sK8={S2ExfF zF#f)q5TUukIr<{2Zz9^~pR<729(v}Y) z0=4i0A1S?+g>sor0>yBl=`2=u^qn36Tl`@GlZCR@%0cw8Q$86E>@=d$dJH8+0rC1o zJ53b;0hAAqk#<>`U@J=VE8hA3#`w`4s2dI~=3KlP)Bvl$X`KWlI||hlvj`i*XBTl{ zc0KCVW+ozaYL>z8z=P`kePq&Th;T0|bOAFD@i;-%1tk!%*#U4e8`2|Q83#P#%pv0~ z|21Q(Y_Gj1^S#O6CZ0e(Vm_qn+PnamS0A;DWptSvGyk*b(w?*gDRwnM6AHf_t+3Sc z^ku2Lg+Syl&aTQ>O8d)D&o#7qS}EL4yl#e$j{&A6y&RF94{}~(g_l$G%ARo~MJF3{ zM7gKdHh4=Q;;8Rl(I{IUu=RQWL`z0WJm}%HodNr>M9@W3rfLBC!!Q8OakCp<0J#$t zrIR^^e)K)}2t{H)fo?1)sTyOx2b}%jS4(M-+J@vHARHTdj}$d z4hv2h?Oi)t9fA_9gq8Ewm7MT>H*hL9wM@k>5WLiV)NWvY0pQYt<+7^?w3xwk$+2A> zzVrTXV=CvqaX`~Wj8-^&U()r)3)7^xi*=IeP2eAL&jSirXyB?GC#^1T;YzVM$!_{U z!xc0j@PB^)i%aYB*mUu=IzB^D8zA=$9(Um6D<8`D%&NY>jC;xdF_}d#`bZXv6Et(V z8B;+*)d8V4D7!^*!saV9;RsTpkpeKWZ<*HV&l41(UtE_lK1arsLi~jP)DU+S2%!@5 zhqsdLC3Tk&qpR~;t!ZqWq#(Qu^g=~hx%k{>ink=nxqf;l-@QI>B&s{dj?nj+>|xxX zUv}|(6oz#mOhUA1Z+BiV9B0$=Njkr{d0?+uumE!>XLwrLGVx}z>NaU=hK2mwyLAuV2JO~Nm5uhgT zIUG9iP`B(~(ei$q8p;H_P?0hfiHWe?%l{5|VuTjur`Z9~){x=>tF*=V1AYFSwOgDxnH0t3Pe zNlRPL1S%?kikEjkbLKfgFBB-$?4q7)!3Dw!1-u~bm*$UuJ5{8tHd0hfgg)K?bIaT3 zio@xr_IZ{M&~Uk7#n1NJz`*4WZ*mm`;Ylu$*u@fK`*0XyVBwyO71rBYu#t*&qP4b1}<*waKu#=I&*KEE!+4!#E=$gCqQUvQzgH;qJ z8cW$}uq>&0zXd4)iNQ^~R6D9&J;OL3n4?khX>}@W<6AMq5cXwtU4y0|{bIDiJ1jFT zpJn>49Q{F>An~xWP%JaU97cn&lRbxb73lOy7=u6`n&qN>bTwHQAvKd{(w!qg$Y zH0uUD&dbrCaWG!;zM}xfqEvD~U2bSB?1pwsjy$HEmgR39Uu7-Z;LkcDk^w9*I4kD+ z#FwRCFz7Xs-_T61DDYp2m5gn-GPJG$Qz^;1CEjFkMar{1SnHD@iI$@}tj1tr?B#_5 z(-`%zf>RX|T1PJ5EB6h;3LS16&&F97^)7 z&l-%&3@Su?8=a8J!Iuijn222>!=7+xhMl>Pa#{Ziat+R0;` zw-xygXp0RRMl57;(7e62Qj~Z%^&uQlzAKX$3s{=8^Ox;58OWw-n9ar_ybuKp zMzN&&1HOvzV<0DLc5*@p12tkrdkV6e6)&0%;vo)qXioaa{r&~3o^oX-qhC!k)0{FK zyV$Z(?b6?(-`F%QP`E(_0hAXu1712Ib(wJbY&R7Wscc3S7>$XQ%DdX&=V(_nRuE%qvL5+0m-c1Hw0y@tLnJ6YYx|N{zuD)s@ zhbbo<>dQ0VOJ&5mBkf0Fl^<^^d7(Zq9DG=1=Kus}21{Fx^wcCN43>vm>7olwW@ZMr z;*Oo);*3UHB-@f%S;UiCg{2ZVw{*(x;ILU&b`ZZWYbjUdJM!{v(U`#GCTaW}xt+nd z`42e-2I&WZo`^`RH$Gf?LdagY<1MQ<$P@sK;K(}TLMNI{A!7ThH2P8?6+2Qs9X2y#z>_U;cJjm1J;LRW*ui{1@a@%1YC=vH2H3-P}a^Sc-{ zb@Q`9oi>sfYb9iMwx<5Twe`s?d{9blYac1w(TzOl|t-nzwk-zm*e^VL3HWF+bg9lQ~g&=p)GPwLb3$XlcO>BWMh$(k>_^;PJap z_d$GA$DTALKD@H&goBs0rz;nP=8{DYRAS3qx-1SWauy!Lxoj%B-+AB_O?PUL6!2lSJ6fuhaT8D`{F?UuR zW-p#$^}E@5b$ne63rwO-fD=mKuanqV>`kj3ET>PUjo@RI5_#=(2vV>zo#otNtz9`0 zWGTq_L8tn$DlmVM8Arpgj!CTg&(1Omkf?B~eF(bWSYeGhqyBLHZ``mh;zo-_WEdXI zMnjAweETB*s3Og62eQ$y+BjF-+3 zI|AQRGT750k6JgEU%u9xQV>8Hs}d{NRgl&kljnEQZ36}LI{X!n%|reZ7v8cbIXkGY z*4x&=5Lhwp(SoE=6z#yl*XLyt44VP39T2+AlDvHD-fj2{27BKLwmkJoyvGYJr|FeE z;z){4Ht3089a1o#po4T^@A7KVE3e;Y0>AzmR2+y%REqX5w=&kyAUr?=k?vCj%*_yMR~IUbx`!9om{p z-tTg!I(Me}yzC5036u%WYv2UN9ql2FF!149qkB+8@fIQ#gXE$J5_cSt>3av2?q?Fo zEdvCqsdJNiN!3(V5@OGhYkFkyRQHvQhI{9__2$$-Ua;yQM@FVS$ixz-sSwuq@6+QZ z%98@E%I&;R)76mv_S&^BbDrLjJXd_;MpnHM?e;v+sFzU1VcY_2?ipLFib?N+D8rK4 zX|J7{4Fg~h%#*Z~ccDLh#rUQBk6f8fErO^^2RSk@$Gs@5yyADOzR72WYBJcmDSO1} z5-}qhiz>v_P*YHGO+J^wj`%?Vx1ervfNhsB7EjaK#^NWC%~uiS*v7yu9qx@-ai5x% zYabM!oSNJ;_Uv0@7dnMtlV8f&8rCR2LwWlFDoclTzVK)!*ODZP2G$Sx;O{!o zOY-DxTi6BM&ENn400IB^HQxds-kLgHa1_PtqY&iu5xG#|RP$@~yYL>yPz+P35RQ#G z8NGA3*~_3thBOfq6sWdR9F@Bz?WP2#eB+ zG$x5`W0_P4dxzWT zr~-Gs8Kv#T#8QMQ^JwQ-R3Uap!xhn)faVEwxh8z^;G*000FCM=r6D!e>cgd_X-e4KZ_z#IdWs z!@~<2e}yE_1@H##%&|4khVNIG%gE?GeRzW*qHPvD5yvEYUc+TSWIJF3z+-=@j&<{e zQ@<+B&zByMvKOu{2VXl+GT}D)SIe=j6#55ALg}VTfqKq_`=*P=bX$C>pK>KLp$fK8plD|QR$iOQvJzi>2Yr@y2kNYm*< z%l?v%^e6AQzZAc5>ys(PuvH0Q=O#uN_oWq=oL{4th6e5ix1b4qjYTf&CnV7PVLf{S zWMrPewre7+XG@-Ekx=~CcETweT;(i9D=592k%R>4@Bzc`ngQR79(4f%tykGX<70;q zo5k=iK;vWLW+$XzJL4(%LvG9VA|-P@t))zP5&9{_6%!HzZ0)UvFp}Cgc%Dm^64|oN zqTa8t?i{Z?%>fd4cnj$2rS}@{8i)!)!PA{1bR{gxaK|laDE`R`fu5gR1p*P<^Z>BxHwaN$gnm0ZI zMN~LdK7?IwtT4u$(f>Gpx9(UMaU(^dvJ4MqBO%5Tarf8yb_N|>hvNdovmRjMlD2}z zA(d8@;ZTq>qcMQ7tyJX2CL31KDWUZ5#!Kf29f5DDnQUp0$E_R7uitA;sR$qp)rl4C zD#&XNN%Ontwt)b~uO|wOKN5LP(q(~OBy+C3bs3PIg|XFY9(utvPdPZD~LMy z_S)u2Nv)XJ^4Id~^fvfCj}891bbu#&;kk-^@$NJIG=+#cx%Bmu@jye!7FVGzjA^Sk z`as|-RB&CCGkAW5cnJ>QO-2mX<*uTwEK=9*r5{aJ@bk8pBrN276A>jPzvaP?fe5Ms}Kyx5an;8 zaOsHa+pV2v0R82Hz(CsgWC}s`G888GI=aCHFNZbv;qRM34#JbNs&a<(JabR{O6~fY zBt&00l z?Kfv2i%bGL?8x1mM%`zlZWSW{v;Hupc)&jp-w?xCsf$HYIV&lTl%97GZnL8g0s?r1 zeatgm*|-Gsc0wK*1$za%0>G+e3wZJLA1Eik7*s8efnZ%Ie$d-UYQt}J(QdU+=U|aY z_Pl3PV?~47V_(|rs<^i64zHG1jIpB)wdeh0D_5y1AgkASw`z^ym&%@FlCC6# zlQHrCd|VEV18;BJZIMDiV>qP6ay?xEgG8`X&vxArRlxz_QwT0W4{LP)?%Q@)6*?u=fTz^+`pbLeH5#31H*tSa%q(TW_5bk}_BijD;ADZ| ze*wdv;~MYOEVMj>BG3R0O3x1x>+jpBewiwu!E+PnIEpxX|E4)ji?5JNYn=FX+egoy)8PASA9LKZZ8japB^RAP+IGRVi^=07!r)il;GrGIDHfim z16f`x4ELj-TYil{H(}C0F{3ve3?G*6>tE zToDFb`LhugB_!DQmS0wrD7G?c*+Wful#{Tg2`M5k%r{w3WCEr040P%{=_1rht@sO& z_Vj^$`+FR~mo&doZGNl!EmEZHTrb9KI-leVyzyQ)UUiuJ{#>2A0$ET$sa_Aq?$0?z zt}`^jMnAaXlg`PzlD0&RPBIseDkm<~T8heyK-wpcz*kWdq)xCljd} zwvZ-nJO%qVp|yW!9tK$*pEoL$aO|7J&j%W_;jXt>eQfpPuP5cq`ty0g5$pf}0jIet zHY8%K_D{Rn+{d_LQ=W;8@|-XO+f#Ga&||hJx|5N+lIr4sBf7gKMy?u#fK>@dcXFEy zH)>Vrj%a3{o5ydbepLAjxvv*A{`-$TKRH_P<9QdcRKez%<%2MZj@h|#;+|J#Y62%! z|DTELrvT4Fa>E4CWrIhB7bO7DHsAONdLeJHQ}k_zVWAkQzW75ezDTp7`gc`~D8b>X zypKqeQw;vnl9_F(Ioxy5h;s!AT@sEd| z!ZTAlG~_of)^}<_e7UbM7;iG`{bP2TrGwSK9Omt7HN-?BmUU1!zL=H@ZU^)+%ovd? ztNJRR%WrYAS7v&)apq)Lc-%jc-yE8NO#_yNOq5)5l7$fL?J_u^a#^a;$OH?ELFCMx z^=gh56j>o(5EoaeBG z?+?x-kD03vi{8yw0Xe4D*z<4zkh~Vpt!UN7fI#v~c7eyXSBcoJJ@A2HTO%O%L|Lv0 zVN|@0R3L1Xf@>a2Mc`7cYo^+pa;y%<2mVskNu767c~jJ9FXOcvcu;WHW-md?(5VC4 zYe=B0^;I5eCQ*Ch(HT zW^a*7D^Mb{Bnwd@2DEJ>yPfn}fhhE^e}A2wgTx>D&H zKwkNVW^Gx42YQ2sLl|(ueR98x^kG2t&`jLa|2+xw=oclk9s(n@bO;y^@jiV4tiRwn zE0nK)VXFL+2sbvqR)SfSb4!7j(V$`t-PBdl^_+NaQMpD(JCS}3R3kJ+5S5v;H70&` zPH8d&GS+flgUC8XQkQ$4Crx}w-e?C;4xrnu*2Y~zLh8fsvEl{q;bNXo`B~M+&+4nb zv(4W>;?Gco$lp{Hn-kLVmo(|I;ekbE5*oXZhM)~(y`-~^j(KdjsMv;x$;z{M^?ahaXAyvovzx{c~X zu0^H7sTF;}mdamPJqpGN)r+&m2fK;7POafbwJm>56*Y@W`;5U|#86CMpH?suq&S2t zP4jsrPr23A#&;H0+$9(46562}N_wWIff$~J8y+)iYW)1~SfPUP4QyX`1Uw=2!W1(i z^rM&zDcr{2L$gypEPju@De!8XDx57LLx)<5%pO%fJ-}de!#m7i7^lbsLY*-2`1&F? zIcdhetQJDd4pVk-q^}X3jJ4G_QUg``EZ*V|xUKztoVg~~SdT6~yf3ny*fNekP_>Y@ zhT20`8+)if);rZq@OuWrK`3XuMj`j6Go_~(1cA%!>i9U;@8XT+@Gn5)Te4b%8nJ&f z4IdKV@Ep17hU-BCCY|4%nE3y`E>0y_cGklfNo^avPbOly9e|MvR5yCJ&)oD}RIAQgJ%?*w zmBdhJNm>y%8pS<*qL^h5*pCv@#8s7mfJ52r<~SyKf!k7>#N=&6PIcU8pb^U^M)p%* zxC_b>23@>?^Uef6dC=$5J1gl7IRu(w^9&&x$HaA7Oj(XF!W|Kpkoa7$tM%<^~{nWh%(Z`f2THTl+~lw+;_QiJ8-3p zOY$Hu=xBLT%@2E!;?R#Pd4FJu*I=iZ0l!sU3QXR)u~z|NFCAbi2jZ>O{N@e^B0_E6 zT&Ik|TsMVkl^O(K|J3V%n9AV9;*6=|raK>Y4gxFBx~TZ!u_pXW_Lox@4tMCm3}NuU zkZUnn3PF_(5UDNG_m^_S&js2A6NEfO+79yDm`8}|dcHD)kw+@Nr?WynQ7%QE4N#Pq z4tUSH(zn}19>Oe?uP%!Ue~4q-GGKb|sQD+GIVP)=!I=qEawdhFU95AW92 zuTXWazQ-<{j!em&C_dd{hOrVcZyB9W;Tmt~t&RGrhXNgZY7MaA(Ta21F^k#K&U;r| zrr5yB&Jfc)5A0OmNU|*yDs@^WQYjhBcrxMM)AnlpfT*p3+{(nNO_SfGrRipP8%>(*5il^;) z$r&(L@oQmkJvvBqxWShGbpiv9XhGCjG+f@~)9L}gs4)-PN{0qXD0a$VARgFEBicdO(Y^2v}JBRoO}zM+^18GRnxdH^?bKQa=FN(XPP>3qrs-17aGX z+t!d?v{xBB5+>M*0MkvXWDHFS{r;&Z{?>3Q4<0EzEJ=w4TL#1>9R7RFa^ZVd1x;EA zX(ankD;nE%j=kYzHYKDJ*@Gbxl(s-p7Wv8tyqx;YV{gR5u*6q^{up2ubc)^)Jdzp8 zl5ZQRp8NcRrnR4jHL})kPBWQ~I@juUNs{OsW^2JXqm|pk*!%CSvhr{&@X=m^r323Qp2g({XsMIY`0+v$;h9ETL)AN= zu#>(h-u1;1tlG-a&a($Ud8&nGO=ack%Urz4Y+J-GmnmVCV}FI`QtK>rs1;H z0cl_-hH>stRYUezp%YTs_%XL`s8yB~7Df|lzXwV$eT<7`OYaXwlrU?o2OM)-f{k)< z>%Tl?EzIA`|G)Ke<8`ZQ4AI?E8Urdn8N^H!g{GHy?iL~cgUWIF3l^SMp}SJ2~GT2H9J z0wx%3`y%()qRNeAwB_YZ)gFA(Wr!LUJR{lK^m6wg(_`fkiZXn+t&uR-pfI(Hc(HtG cfmLo3{HZ7n$CSBiw`i74-I-aS;6MNX0MB}T_W%F@ diff --git a/wiki/design/pr-1125-verification/legacy.webp b/wiki/design/pr-1125-verification/legacy.webp index beebdc7c1ada3a3cc38a6cef0948b4ac1c296f6c..6e6eb0643d2bea10fbff62ab09ffb7411cf2ba0e 100644 GIT binary patch literal 32566 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 literal 33252 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

2OkoR#G8CRU)mn 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 m5fA%s7JlA_VC<*LmyPoAVK`ZZUrvLmSM=Z8E9{ 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 Date: Fri, 23 Oct 2026 11:33:31 +0530 Subject: [PATCH 5/6] Refine accessory finishes and support paint across roof and HVAC models --- .../core/src/schema/nodes/hvac-equipment.ts | 1 + packages/core/src/schema/nodes/lineset.ts | 1 + packages/core/src/schema/nodes/liquid-line.ts | 1 + packages/core/src/schema/nodes/ridge-vent.ts | 1 + packages/nodes/src/box-vent/geometry.ts | 7 +- packages/nodes/src/box-vent/renderer.tsx | 13 ++- packages/nodes/src/chimney/renderer.tsx | 6 +- packages/nodes/src/cupola/geometry.ts | 10 ++- packages/nodes/src/cupola/renderer.tsx | 13 ++- packages/nodes/src/dormer/renderer.tsx | 6 +- packages/nodes/src/downspout/geometry.ts | 10 ++- packages/nodes/src/downspout/renderer.tsx | 6 +- packages/nodes/src/duct-segment/geometry.ts | 2 +- packages/nodes/src/eyebrow-vent/geometry.ts | 7 +- packages/nodes/src/eyebrow-vent/renderer.tsx | 13 ++- packages/nodes/src/gutter/geometry.ts | 10 ++- packages/nodes/src/gutter/renderer.tsx | 6 +- .../nodes/src/hvac-equipment/definition.ts | 8 ++ packages/nodes/src/hvac-equipment/geometry.ts | 30 ++++++- .../src/hvac-equipment/hvac-equipment.test.ts | 71 +++++++++++++++- .../nodes/src/lean-to-extension/geometry.ts | 2 +- packages/nodes/src/lineset/definition.ts | 8 +- packages/nodes/src/lineset/geometry.ts | 25 +++++- packages/nodes/src/liquid-line/definition.ts | 5 +- packages/nodes/src/liquid-line/geometry.ts | 25 +++++- .../src/ridge-vent/__tests__/schema.test.ts | 15 ++++ packages/nodes/src/ridge-vent/geometry.ts | 10 ++- packages/nodes/src/ridge-vent/renderer.tsx | 8 +- packages/nodes/src/shared/accessory-finish.ts | 58 +++++++++++++ .../nodes/src/shared/accessory-normals.ts | 9 ++ .../nodes/src/shared/mechanical-finish.ts | 78 ++++++++++++++++++ packages/nodes/src/shared/mechanical-paint.ts | 9 ++ .../src/skylight/__tests__/schema.test.ts | 45 ++++++++++ packages/nodes/src/skylight/definition.ts | 2 + packages/nodes/src/skylight/paint.ts | 36 ++++++++ packages/nodes/src/skylight/renderer.tsx | 6 +- .../src/solar-panel/__tests__/schema.test.ts | 45 ++++++++++ packages/nodes/src/solar-panel/definition.ts | 2 + packages/nodes/src/solar-panel/geometry.ts | 34 ++++---- packages/nodes/src/solar-panel/paint.ts | 37 +++++++++ packages/nodes/src/solar-panel/renderer.tsx | 4 +- packages/nodes/src/turbine-vent/geometry.ts | 13 ++- packages/nodes/src/turbine-vent/renderer.tsx | 11 ++- wiki/design/pr-1125-verification/README.md | 15 ++++ .../paint-persistence.webp | Bin 0 -> 39732 bytes 45 files changed, 661 insertions(+), 63 deletions(-) create mode 100644 packages/nodes/src/shared/accessory-finish.ts create mode 100644 packages/nodes/src/shared/accessory-normals.ts create mode 100644 packages/nodes/src/shared/mechanical-finish.ts create mode 100644 packages/nodes/src/shared/mechanical-paint.ts create mode 100644 packages/nodes/src/skylight/paint.ts create mode 100644 packages/nodes/src/solar-panel/paint.ts create mode 100644 wiki/design/pr-1125-verification/paint-persistence.webp diff --git a/packages/core/src/schema/nodes/hvac-equipment.ts b/packages/core/src/schema/nodes/hvac-equipment.ts index 359d507e8a..2ef285c0ee 100644 --- a/packages/core/src/schema/nodes/hvac-equipment.ts +++ b/packages/core/src/schema/nodes/hvac-equipment.ts @@ -19,6 +19,7 @@ import { BaseNode, nodeType, objectId } from '../base' export const HvacEquipmentNode = BaseNode.extend({ id: objectId('hvac-equipment'), type: nodeType('hvac-equipment'), + slots: z.record(z.string(), z.string()).optional(), // Level-local meters, y at the unit's base. position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), // Yaw in radians. diff --git a/packages/core/src/schema/nodes/lineset.ts b/packages/core/src/schema/nodes/lineset.ts index 0917a74bf2..1eabc8f711 100644 --- a/packages/core/src/schema/nodes/lineset.ts +++ b/packages/core/src/schema/nodes/lineset.ts @@ -8,6 +8,7 @@ import { BaseNode, nodeType, objectId } from '../base' export const LinesetNode = BaseNode.extend({ id: objectId('lineset'), type: nodeType('lineset'), + slots: z.record(z.string(), z.string()).optional(), // Polyline path in level-local meters. Minimum two points (start, end). path: z.array(z.tuple([z.number(), z.number(), z.number()])).min(2), // Nominal suction-line copper OD in inches (the large insulated line). diff --git a/packages/core/src/schema/nodes/liquid-line.ts b/packages/core/src/schema/nodes/liquid-line.ts index 5b3f8b386e..ac85b395f4 100644 --- a/packages/core/src/schema/nodes/liquid-line.ts +++ b/packages/core/src/schema/nodes/liquid-line.ts @@ -14,6 +14,7 @@ import { BaseNode, nodeType, objectId } from '../base' export const LiquidLineNode = BaseNode.extend({ id: objectId('liquid-line'), type: nodeType('liquid-line'), + slots: z.record(z.string(), z.string()).optional(), // Polyline path in level-local meters. Minimum two points (start, end). path: z.array(z.tuple([z.number(), z.number(), z.number()])).min(2), // Nominal copper OD in inches. Common residential sizes are 1/4"–3/8". diff --git a/packages/core/src/schema/nodes/ridge-vent.ts b/packages/core/src/schema/nodes/ridge-vent.ts index 7b7e1c8704..aa321ea946 100644 --- a/packages/core/src/schema/nodes/ridge-vent.ts +++ b/packages/core/src/schema/nodes/ridge-vent.ts @@ -42,6 +42,7 @@ export type RidgeVentLine = { export const RidgeVentNode = BaseNode.extend({ id: objectId('rvent'), type: nodeType('ridge-vent'), + slots: z.record(z.string(), z.string()).optional(), material: MaterialSchema.optional(), // Unpainted ridge vents inherit the roof top material in the renderer. diff --git a/packages/nodes/src/box-vent/geometry.ts b/packages/nodes/src/box-vent/geometry.ts index 73b12ef545..4c58c04e6b 100644 --- a/packages/nodes/src/box-vent/geometry.ts +++ b/packages/nodes/src/box-vent/geometry.ts @@ -1,6 +1,7 @@ import { type BoxVentNode, getActiveRoofHeight, type RoofType } from '@pascal-app/core' import * as THREE from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' +import { finishAccessoryNormals } from '../shared/accessory-normals' import { copyUvToSecondaryChannel } from '../shared/primitive-uv' import { boxVentDimensions } from './dimensions' @@ -10,7 +11,7 @@ export const BOX_VENT_MATERIAL_INDEX = { } as const /** Shared housing and flashing geometry for the renderer, preview and exports. */ -export function buildBoxVentGeometry(input: BoxVentNode): THREE.BufferGeometry { +function buildUnsmoothedBoxVentGeometry(input: BoxVentNode): THREE.BufferGeometry { const node = { ...input, ...boxVentDimensions(input) } const housing = node.style === 'box' @@ -706,3 +707,7 @@ export function computeBoxVentSlopeTilt( const slopeAngle = Math.atan2(rh, segment.depth / 2) return localZ > 0 ? slopeAngle : -slopeAngle } + +export function buildBoxVentGeometry(input: BoxVentNode): THREE.BufferGeometry { + return finishAccessoryNormals(buildUnsmoothedBoxVentGeometry(input)) +} diff --git a/packages/nodes/src/box-vent/renderer.tsx b/packages/nodes/src/box-vent/renderer.tsx index 8cea3f59bf..ce333a31fb 100644 --- a/packages/nodes/src/box-vent/renderer.tsx +++ b/packages/nodes/src/box-vent/renderer.tsx @@ -12,7 +12,6 @@ import { type ColorPreset, createMaterial, createMaterialFromPresetRef, - createSurfaceRoleMaterial, getRoofOuterSurfaceFrameAtPoint, resolveMaterialRef, useNodeEvents, @@ -20,6 +19,7 @@ import { } from '@pascal-app/viewer' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' +import { accessoryFinish } from '../shared/accessory-finish' import { surfaceQuatFromNormal } from '../shared/roof-surface' import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip' import { buildBoxVentGeometry } from './geometry' @@ -124,7 +124,7 @@ const BoxVentRenderer = ({ node: storeNode }: { node: BoxVentNode }) => { // eaves; that's now a known visual tradeoff — a closed-solid extrude in // `geometry.ts` is the right fix if undersides become noticeable. const material = useMemo(() => { - const roleDefault = createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) + const roleDefault = accessoryFinish('roof', colorPreset, THREE.FrontSide, sceneTheme, shading) if (!textures) return [roleDefault, roleDefault] const resolve = (role: 'base' | 'top') => { const slotMaterial = resolveMaterialRef(node.slots?.[role], sceneMaterials, shading) @@ -133,7 +133,14 @@ const BoxVentRenderer = ({ node: storeNode }: { node: BoxVentNode }) => { if (node.materialPreset) { return createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial } - return roleDefault + return accessoryFinish( + 'roof', + colorPreset, + THREE.FrontSide, + sceneTheme, + shading, + role === 'base' ? 'metal' : 'coated', + ) } return [resolve('base'), resolve('top')] }, [ diff --git a/packages/nodes/src/chimney/renderer.tsx b/packages/nodes/src/chimney/renderer.tsx index a4d54d637b..9462c6ab15 100644 --- a/packages/nodes/src/chimney/renderer.tsx +++ b/packages/nodes/src/chimney/renderer.tsx @@ -12,13 +12,13 @@ import { type ColorPreset, createMaterial, createMaterialFromPresetRef, - createSurfaceRoleMaterial, getRoofSegmentBrushes, useNodeEvents, useViewer, } from '@pascal-app/viewer' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' +import { accessoryFinish } from '../shared/accessory-finish' import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip' import { buildChimneyGeometry } from './geometry' import { carveChimneyHoles } from './holes' @@ -184,7 +184,7 @@ const ChimneyRenderer = ({ node: storeNode }: { node: ChimneyNode }) => { // the themed 'wall' role colour; only an explicit preset/material keeps // its texture when textures are on. if (!textures || (!node.material && !node.materialPreset)) { - return createSurfaceRoleMaterial('wall', colorPreset, undefined, sceneTheme) + return accessoryFinish('wall', colorPreset, undefined, sceneTheme, shading) } if (node.material) return createMaterial(node.material, shading) return createMaterialFromPresetRef(node.materialPreset, shading) ?? fallbackBodyMaterial @@ -206,7 +206,7 @@ const ChimneyRenderer = ({ node: storeNode }: { node: ChimneyNode }) => { !textures || (!node.topMaterial && !node.topMaterialPreset && !node.material && !node.materialPreset) ) { - return createSurfaceRoleMaterial('roof', colorPreset, undefined, sceneTheme) + return accessoryFinish('roof', colorPreset, undefined, sceneTheme, shading) } if (node.topMaterial) return createMaterial(node.topMaterial, shading) const preset = createMaterialFromPresetRef(node.topMaterialPreset, shading) diff --git a/packages/nodes/src/cupola/geometry.ts b/packages/nodes/src/cupola/geometry.ts index 09b3f73c86..c403620b7c 100644 --- a/packages/nodes/src/cupola/geometry.ts +++ b/packages/nodes/src/cupola/geometry.ts @@ -1,5 +1,6 @@ import type { CupolaNode, RoofSegmentNode } from '@pascal-app/core' import * as THREE from 'three' +import { finishAccessoryNormals } from '../shared/accessory-normals' import { copyUvToSecondaryChannel, cumulativeProfileDistances, @@ -35,7 +36,7 @@ export const CUPOLA_MATERIAL_INDEX = { * Pure: no React, no scene access, no store mutation. Safe for unit tests, * the placement preview, and the move-tool ghost. */ -export function buildCupolaGeometry( +function buildUnsmoothedCupolaGeometry( node: CupolaNode, segment?: RoofSegmentNode, ): THREE.BufferGeometry { @@ -495,3 +496,10 @@ function pushTri( } for (let i = 0; i < 3; i++) normals.push(nx, ny, nz) } + +export function buildCupolaGeometry( + node: CupolaNode, + segment?: RoofSegmentNode, +): THREE.BufferGeometry { + return finishAccessoryNormals(buildUnsmoothedCupolaGeometry(node, segment)) +} diff --git a/packages/nodes/src/cupola/renderer.tsx b/packages/nodes/src/cupola/renderer.tsx index 25dd8460f9..6a8d73df7c 100644 --- a/packages/nodes/src/cupola/renderer.tsx +++ b/packages/nodes/src/cupola/renderer.tsx @@ -12,13 +12,13 @@ import { type ColorPreset, createMaterial, createMaterialFromPresetRef, - createSurfaceRoleMaterial, resolveMaterialRef, useNodeEvents, useViewer, } from '@pascal-app/viewer' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' +import { accessoryFinish } from '../shared/accessory-finish' import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip' import { buildCupolaGeometry } from './geometry' @@ -73,7 +73,7 @@ const CupolaRenderer = ({ node: storeNode }: { node: CupolaNode }) => { useEffect(() => () => geometry.dispose(), [geometry]) const material = useMemo(() => { - const roleDefault = createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) + const roleDefault = accessoryFinish('roof', colorPreset, THREE.FrontSide, sceneTheme, shading) const resolve = (role: 'base' | 'body' | 'roof' | 'louvers') => { if (!textures) return roleDefault const slotMaterial = resolveMaterialRef(node.slots?.[role], sceneMaterials, shading) @@ -82,7 +82,14 @@ const CupolaRenderer = ({ node: storeNode }: { node: CupolaNode }) => { if (node.materialPreset) { return createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial } - return roleDefault + return accessoryFinish( + 'roof', + colorPreset, + THREE.FrontSide, + sceneTheme, + shading, + role === 'roof' ? 'metal' : 'coated', + ) } return [resolve('base'), resolve('body'), resolve('roof'), resolve('louvers')] }, [ diff --git a/packages/nodes/src/dormer/renderer.tsx b/packages/nodes/src/dormer/renderer.tsx index 837771586a..bf00fda561 100644 --- a/packages/nodes/src/dormer/renderer.tsx +++ b/packages/nodes/src/dormer/renderer.tsx @@ -17,7 +17,6 @@ import { type ColorPreset, createMaterial, createMaterialFromPresetRef, - createSurfaceRoleMaterial, NodeRenderer, useNodeEvents, useViewer, @@ -25,6 +24,7 @@ import { import { type ReactNode, useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { useShallow } from 'zustand/react/shallow' +import { accessoryFinish } from '../shared/accessory-finish' import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip' import { buildDormerFallbackGeometry, @@ -95,8 +95,8 @@ const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => { // role colour regardless of explicit paint (the render-modes invariant). // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. const material = useMemo(() => { - const wallRole = () => createSurfaceRoleMaterial('wall', colorPreset, undefined, sceneTheme) - const roofRole = () => createSurfaceRoleMaterial('roof', colorPreset, undefined, sceneTheme) + const wallRole = () => accessoryFinish('wall', colorPreset, undefined, sceneTheme, shading) + const roofRole = () => accessoryFinish('roof', colorPreset, undefined, sceneTheme, shading) const top = getEffectiveDormerSurfaceMaterial(node, 'top') const side = getEffectiveDormerSurfaceMaterial(node, 'side') diff --git a/packages/nodes/src/downspout/geometry.ts b/packages/nodes/src/downspout/geometry.ts index f25d6a2661..392c291fff 100644 --- a/packages/nodes/src/downspout/geometry.ts +++ b/packages/nodes/src/downspout/geometry.ts @@ -2,6 +2,7 @@ import type { DownspoutNode } from '@pascal-app/core' import * as THREE from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import type { OutletDims } from '../gutter/profile-geometry' +import { finishAccessoryNormals } from '../shared/accessory-normals' import { applyCylinderWorldUvs, applyPlanarWorldUvs, @@ -54,7 +55,7 @@ const SPLASH_LENGTH = 0.34 const SPLASH_THICKNESS = 0.05 const SPLASH_TILT = 0.1 -export function buildDownspoutGeometry( +function buildUnsmoothedDownspoutGeometry( node: DownspoutNode, routing?: DownspoutRouting | null, ): THREE.BufferGeometry { @@ -263,3 +264,10 @@ function buildSplash(path: DownspoutPath): THREE.BufferGeometry | null { slab.translate(bx, by - SPLASH_THICKNESS / 2, bz + SPLASH_LENGTH / 2) return slab } + +export function buildDownspoutGeometry( + node: DownspoutNode, + routing?: DownspoutRouting | null, +): THREE.BufferGeometry { + return finishAccessoryNormals(buildUnsmoothedDownspoutGeometry(node, routing)) +} diff --git a/packages/nodes/src/downspout/renderer.tsx b/packages/nodes/src/downspout/renderer.tsx index f16bb2040a..a4e495ccfb 100644 --- a/packages/nodes/src/downspout/renderer.tsx +++ b/packages/nodes/src/downspout/renderer.tsx @@ -13,7 +13,6 @@ import { type ColorPreset, createMaterial, createMaterialFromPresetRef, - createSurfaceRoleMaterial, resolveMaterialRef, useNodeEvents, useViewer, @@ -22,6 +21,7 @@ import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { computeEaveY } from '../gutter/eave-snap' import { resolveGutterOutletById } from '../gutter/outlet-lookup' +import { accessoryFinish } from '../shared/accessory-finish' import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip' import { buildDownspoutGeometry } from './geometry' import { computeDownspoutRouting } from './routing' @@ -138,12 +138,12 @@ const DownspoutRenderer = ({ node: storeNode }: { node: DownspoutNode }) => { const material = useMemo(() => { if (!textures) { - return createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) + return accessoryFinish('roof', colorPreset, THREE.FrontSide, sceneTheme, shading, 'metal') } const slotMaterial = resolveMaterialRef(node.slots?.surface, sceneMaterials, shading) if (slotMaterial) return slotMaterial if (!node.material && !node.materialPreset) { - return createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) + return accessoryFinish('roof', colorPreset, THREE.FrontSide, sceneTheme, shading, 'metal') } return node.material ? createMaterial(node.material, shading) diff --git a/packages/nodes/src/duct-segment/geometry.ts b/packages/nodes/src/duct-segment/geometry.ts index 8eec23845b..91d6f15897 100644 --- a/packages/nodes/src/duct-segment/geometry.ts +++ b/packages/nodes/src/duct-segment/geometry.ts @@ -360,7 +360,7 @@ export function createDuctMaterial( if (resolved) return resolved } - return resolveSlotDefaultMaterial(DUCT_BODY_SLOT_DEFAULT, shading, 0.7) + return resolveSlotDefaultMaterial(DUCT_BODY_SLOT_DEFAULT, shading, 0.48) } /** diff --git a/packages/nodes/src/eyebrow-vent/geometry.ts b/packages/nodes/src/eyebrow-vent/geometry.ts index c135529c55..c6c71e9a3b 100644 --- a/packages/nodes/src/eyebrow-vent/geometry.ts +++ b/packages/nodes/src/eyebrow-vent/geometry.ts @@ -1,5 +1,6 @@ import type { EyebrowVentNode } from '@pascal-app/core' import * as THREE from 'three' +import { finishAccessoryNormals } from '../shared/accessory-normals' import { copyUvToSecondaryChannel, cumulativeProfileDistances, @@ -33,7 +34,7 @@ export const EYEBROW_VENT_MATERIAL_INDEX = { * Pure: no React, no scene access, no store mutation. Safe for unit tests, the * placement preview, and the move-tool ghost. */ -export function buildEyebrowVentGeometry(node: EyebrowVentNode): THREE.BufferGeometry { +function buildUnsmoothedEyebrowVentGeometry(node: EyebrowVentNode): THREE.BufferGeometry { const w = Math.max(0.15, node.width) const d = Math.max(0.15, node.depth) const h = Math.max(0.06, node.height) @@ -605,3 +606,7 @@ function pushTri( } for (let i = 0; i < 3; i++) normals.push(nx, ny, nz) } + +export function buildEyebrowVentGeometry(node: EyebrowVentNode): THREE.BufferGeometry { + return finishAccessoryNormals(buildUnsmoothedEyebrowVentGeometry(node)) +} diff --git a/packages/nodes/src/eyebrow-vent/renderer.tsx b/packages/nodes/src/eyebrow-vent/renderer.tsx index 048a8892a6..fe89b95663 100644 --- a/packages/nodes/src/eyebrow-vent/renderer.tsx +++ b/packages/nodes/src/eyebrow-vent/renderer.tsx @@ -12,7 +12,6 @@ import { type ColorPreset, createMaterial, createMaterialFromPresetRef, - createSurfaceRoleMaterial, getRoofOuterSurfaceFrameAtPoint, resolveMaterialRef, useNodeEvents, @@ -20,6 +19,7 @@ import { } from '@pascal-app/viewer' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' +import { accessoryFinish } from '../shared/accessory-finish' import { surfaceQuatFromNormal } from '../shared/roof-surface' import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip' import { buildEyebrowVentGeometry } from './geometry' @@ -77,7 +77,7 @@ const EyebrowVentRenderer = ({ node: storeNode }: { node: EyebrowVentNode }) => }, [segment, node.position[0], node.position[2]]) const material = useMemo(() => { - const roleDefault = createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) + const roleDefault = accessoryFinish('roof', colorPreset, THREE.FrontSide, sceneTheme, shading) const resolve = (role: 'hood' | 'front') => { if (!textures) return roleDefault const slotMaterial = resolveMaterialRef(node.slots?.[role], sceneMaterials, shading) @@ -86,7 +86,14 @@ const EyebrowVentRenderer = ({ node: storeNode }: { node: EyebrowVentNode }) => if (node.materialPreset) { return createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial } - return roleDefault + return accessoryFinish( + 'roof', + colorPreset, + THREE.FrontSide, + sceneTheme, + shading, + role === 'hood' ? 'metal' : 'coated', + ) } return [resolve('hood'), resolve('front')] }, [ diff --git a/packages/nodes/src/gutter/geometry.ts b/packages/nodes/src/gutter/geometry.ts index e658825629..39515827e7 100644 --- a/packages/nodes/src/gutter/geometry.ts +++ b/packages/nodes/src/gutter/geometry.ts @@ -8,6 +8,7 @@ import { } from '@pascal-app/viewer' import * as THREE from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' +import { finishAccessoryNormals } from '../shared/accessory-normals' import { applyCylinderWorldUvs, applyPlanarWorldUvs, @@ -58,7 +59,7 @@ import { * * Pure: no React, no scene access, no store mutation. */ -export function buildGutterGeometry( +function buildUnsmoothedGutterGeometry( node: GutterNode, mitres: GutterMitres = NO_MITRES, ): THREE.BufferGeometry { @@ -767,3 +768,10 @@ function buildOutletDrill(p: OutletPlacement, size: number): THREE.BufferGeometr drill.translate(p.x, centerY, p.z) return drill } + +export function buildGutterGeometry( + node: GutterNode, + mitres: GutterMitres = NO_MITRES, +): THREE.BufferGeometry { + return finishAccessoryNormals(buildUnsmoothedGutterGeometry(node, mitres)) +} diff --git a/packages/nodes/src/gutter/renderer.tsx b/packages/nodes/src/gutter/renderer.tsx index 39a3c73496..64e0564a4a 100644 --- a/packages/nodes/src/gutter/renderer.tsx +++ b/packages/nodes/src/gutter/renderer.tsx @@ -12,7 +12,6 @@ import { type ColorPreset, createMaterial, createMaterialFromPresetRef, - createSurfaceRoleMaterial, resolveMaterialRef, useNodeEvents, useViewer, @@ -20,6 +19,7 @@ import { import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { useShallow } from 'zustand/react/shallow' +import { accessoryFinish } from '../shared/accessory-finish' import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip' import { computeGutterMitres, type GutterWithSegment, NO_MITRES } from './corner-mitre' import { computeSharedEaveY } from './eave-align' @@ -235,12 +235,12 @@ const GutterRenderer = ({ node: storeNode }: { node: GutterNode }) => { // needed. const material = useMemo(() => { if (!textures) { - return createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) + return accessoryFinish('roof', colorPreset, THREE.FrontSide, sceneTheme, shading, 'metal') } const slotMaterial = resolveMaterialRef(node.slots?.gutter, sceneMaterials, shading) if (slotMaterial) return slotMaterial if (!node.material && !node.materialPreset) { - return createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) + return accessoryFinish('roof', colorPreset, THREE.FrontSide, sceneTheme, shading, 'metal') } return node.material ? createMaterial(node.material, shading) diff --git a/packages/nodes/src/hvac-equipment/definition.ts b/packages/nodes/src/hvac-equipment/definition.ts index 07acbd0f69..8ae8def16d 100644 --- a/packages/nodes/src/hvac-equipment/definition.ts +++ b/packages/nodes/src/hvac-equipment/definition.ts @@ -1,4 +1,5 @@ import type { NodeDefinition } from '@pascal-app/core' +import { mechanicalPaint } from '../shared/mechanical-paint' import { buildHvacEquipmentFloorplan } from './floorplan' import { buildHvacEquipmentGeometry } from './geometry' import { hvacEquipmentToolOptions } from './options' @@ -50,6 +51,12 @@ export const hvacEquipmentDefinition: NodeDefinition = }), capabilities: { + paint: mechanicalPaint, + slots: () => [ + { slotId: 'cabinet', label: 'Cabinet' }, + { slotId: 'mechanical', label: 'Mechanical parts' }, + { slotId: 'service', label: 'Service connections' }, + ], selectable: { hitVolume: 'bbox' }, movable: { axes: ['x', 'z'], gridSnap: true }, rotatable: { axes: ['y'], snapAngles: [Math.PI / 4] }, @@ -72,6 +79,7 @@ export const hvacEquipmentDefinition: NodeDefinition = geometry: buildHvacEquipmentGeometry, geometryKey: (n) => JSON.stringify([ + n.slots, n.equipmentType, n.supplyFace, n.returnFace, diff --git a/packages/nodes/src/hvac-equipment/geometry.ts b/packages/nodes/src/hvac-equipment/geometry.ts index 2a1ab0234a..5840fb785b 100644 --- a/packages/nodes/src/hvac-equipment/geometry.ts +++ b/packages/nodes/src/hvac-equipment/geometry.ts @@ -1,3 +1,5 @@ +import type { GeometryContext } from '@pascal-app/core' +import type { ColorPreset, RenderShading } from '@pascal-app/viewer' import { BoxGeometry, type BufferGeometry, @@ -17,6 +19,7 @@ import { INCHES_TO_METERS, rectSectionAxes, } from '../duct-segment/geometry' +import { finishMechanicalGeometry } from '../shared/mechanical-finish' import { localEquipmentPorts, localRefrigerantPorts } from './ports' import type { HvacEquipmentNode } from './schema' @@ -64,7 +67,7 @@ const UP = new Vector3(0, 1, 0) * the front and finned coil bands down the sides (vertical fan-coil look). * Condenser: squat cabinet with a fan ring and hub on top. */ -export function buildHvacEquipmentGeometry(node: HvacEquipmentNode): Group { +function buildUnpaintedGeometry(node: HvacEquipmentNode): Group { const group = new Group() if (node.equipmentType === 'condenser') return buildCondenser(node, group) if (node.equipmentType === 'air-handler') return buildAirHandler(node, group) @@ -818,3 +821,28 @@ function buildAirHandler(node: HvacEquipmentNode, group: Group): Group { buildServiceValves(node, group) return group } + +export function buildHvacEquipmentGeometry( + node: HvacEquipmentNode, + ctx?: GeometryContext, + shading: RenderShading = 'rendered', + textures = true, + colorPreset: ColorPreset = 'clay', + sceneTheme?: string, +): Group { + return finishMechanicalGeometry( + buildUnpaintedGeometry(node), + node.slots, + (name) => + name.startsWith('service-') || name.startsWith('gas-') + ? 'service' + : name.startsWith('equipment-') && !name.includes('collar') && !name.includes('control') + ? 'cabinet' + : 'mechanical', + ctx, + shading, + textures, + colorPreset, + sceneTheme, + ) +} diff --git a/packages/nodes/src/hvac-equipment/hvac-equipment.test.ts b/packages/nodes/src/hvac-equipment/hvac-equipment.test.ts index 3eba02a660..c58d2dea8b 100644 --- a/packages/nodes/src/hvac-equipment/hvac-equipment.test.ts +++ b/packages/nodes/src/hvac-equipment/hvac-equipment.test.ts @@ -1,7 +1,7 @@ import { expect, test } from 'bun:test' import { HvacEquipmentNode, LinesetNode, LiquidLineNode, portConnection } from '@pascal-app/core' import { useEditor } from '@pascal-app/editor' -import { Box3, type Group, Vector3 } from 'three' +import { Box3, type Group, Mesh, Vector3 } from 'three' import { hvacEquipmentDefinition, linesetDefinition, liquidLineDefinition } from '../index' const equipment = HvacEquipmentNode.parse({}) @@ -100,3 +100,72 @@ test('equipment choices apply valid named cabinet presets before placement', () useEditor.setState({ toolDefaults: previous }) } }) + +for (const [definition, schema] of [ + [hvacEquipmentDefinition, HvacEquipmentNode], + [linesetDefinition, LinesetNode], + [liquidLineDefinition, LiquidLineNode], +] as const) { + test(`${definition.kind} paint survives reload without moving geometry or adding triangles`, () => { + const node = schema.parse({ + path: [ + [0, 0, 0], + [2, 0, 0], + ], + }) + const before = JSON.stringify(node) + const build = definition.geometry as (value: typeof node, ctx: never) => Group + const original = build(node, {} as never) + expect(JSON.stringify(node)).toBe(before) + expect(node.slots).toBeUndefined() + const meshes: Mesh[] = [] + original.traverse((object) => { + if (object instanceof Mesh) meshes.push(object) + }) + expect(meshes.length).toBeGreaterThan(0) + for (const mesh of meshes) { + const role = definition.capabilities!.paint!.resolveRole({ node, hitObject: mesh } as never) + expect(role).toBe(mesh.userData.slotId) + expect(role).toBeTruthy() + } + const role = meshes[0]!.userData.slotId as string + const paint = definition.capabilities!.paint! + const patch = paint.buildPatch({ + node, + role, + material: undefined, + materialPreset: 'library:preset-white', + }) + const restored = schema.parse(JSON.parse(JSON.stringify({ ...node, ...patch }))) + expect(restored.slots?.[role]).toBe('library:preset-white') + const painted = build(restored, {} as never) + const snapshot = (group: Group) => { + const result: unknown[] = [] + group.traverse((object) => { + if (!(object instanceof Mesh)) return + result.push({ + name: object.name, + position: object.position.toArray(), + vertices: Array.from(object.geometry.getAttribute('position').array), + indices: object.geometry.index ? Array.from(object.geometry.index.array) : null, + }) + }) + return result + } + expect(snapshot(painted)).toEqual(snapshot(original)) + const paintedMesh = painted.getObjectByName(meshes[0]!.name) as Mesh + expect(paintedMesh.material).not.toBe(meshes[0]!.material) + meshes[0]!.userData.__fromGeometry = true + const previous = meshes[0]!.material + const restore = paint.applyPreview({ + node, + role, + root: original, + material: undefined, + materialPreset: 'library:preset-white', + }) + expect(meshes[0]!.material).not.toBe(previous) + restore?.() + expect(meshes[0]!.material).toBe(previous) + }) +} diff --git a/packages/nodes/src/lean-to-extension/geometry.ts b/packages/nodes/src/lean-to-extension/geometry.ts index b0f22b6868..6270272f06 100644 --- a/packages/nodes/src/lean-to-extension/geometry.ts +++ b/packages/nodes/src/lean-to-extension/geometry.ts @@ -205,7 +205,7 @@ function resolveLeanToSlotMaterial( return ( (ref ? resolveMaterialRef(ref, ctx?.materials, shading) : null) ?? (slotDefault - ? resolveSlotDefaultMaterial(slotDefault, shading) + ? resolveSlotDefaultMaterial(slotDefault, shading, slotId === 'footings' ? 0.9 : 0.48) : createSurfaceRoleMaterial(role, colorPreset, FrontSide, sceneTheme)) ) } diff --git a/packages/nodes/src/lineset/definition.ts b/packages/nodes/src/lineset/definition.ts index df35fd2492..ccdc92bd0c 100644 --- a/packages/nodes/src/lineset/definition.ts +++ b/packages/nodes/src/lineset/definition.ts @@ -1,4 +1,5 @@ import type { NodeDefinition } from '@pascal-app/core' +import { mechanicalPaint } from '../shared/mechanical-paint' import { createPathPointMoveAffordance } from '../shared/path-point-affordance' import { buildLinesetFloorplan } from './floorplan' import { buildLinesetGeometry } from './geometry' @@ -43,6 +44,11 @@ export const linesetDefinition: NodeDefinition = { }), capabilities: { + paint: mechanicalPaint, + slots: () => [ + { slotId: 'copper', label: 'Copper' }, + { slotId: 'insulation', label: 'Insulation' }, + ], selectable: { hitVolume: 'bbox' }, duplicable: true, deletable: true, @@ -51,7 +57,7 @@ export const linesetDefinition: NodeDefinition = { parametrics: linesetParametrics, geometry: buildLinesetGeometry, - geometryKey: (n) => JSON.stringify([n.path, n.suctionDiameter, n.insulated]), + geometryKey: (n) => JSON.stringify([n.slots, n.path, n.suctionDiameter, n.insulated]), // Open run ends as typed refrigerant ports — directions point outward // along the path tangent so they mate flush onto a service valve. Path diff --git a/packages/nodes/src/lineset/geometry.ts b/packages/nodes/src/lineset/geometry.ts index 154a7c3e60..793490cb08 100644 --- a/packages/nodes/src/lineset/geometry.ts +++ b/packages/nodes/src/lineset/geometry.ts @@ -1,5 +1,8 @@ +import type { GeometryContext } from '@pascal-app/core' +import type { ColorPreset, RenderShading } from '@pascal-app/viewer' import { CylinderGeometry, Group, Mesh, MeshStandardMaterial, SphereGeometry, Vector3 } from 'three' import { INCHES_TO_METERS } from '../duct-segment/geometry' +import { finishMechanicalGeometry } from '../shared/mechanical-finish' import type { LinesetNode } from './schema' const RADIAL_SEGMENTS = 16 @@ -56,7 +59,7 @@ function buildRun( * Children are level-local meters; `` owns the * node transform (identity today — the path is absolute within the level). */ -export function buildLinesetGeometry(node: LinesetNode): Group { +function buildUnpaintedGeometry(node: LinesetNode): Group { const group = new Group() if (node.path.length < 2) return group @@ -109,3 +112,23 @@ export function buildLinesetGeometry(node: LinesetNode): Group { return group } + +export function buildLinesetGeometry( + node: LinesetNode, + ctx?: GeometryContext, + shading: RenderShading = 'rendered', + textures = true, + colorPreset: ColorPreset = 'clay', + sceneTheme?: string, +): Group { + return finishMechanicalGeometry( + buildUnpaintedGeometry(node), + node.slots, + (name) => (name.includes('jacket') ? 'insulation' : 'copper'), + ctx, + shading, + textures, + colorPreset, + sceneTheme, + ) +} diff --git a/packages/nodes/src/liquid-line/definition.ts b/packages/nodes/src/liquid-line/definition.ts index 7272575f3d..5795019e0f 100644 --- a/packages/nodes/src/liquid-line/definition.ts +++ b/packages/nodes/src/liquid-line/definition.ts @@ -1,4 +1,5 @@ import type { NodeDefinition } from '@pascal-app/core' +import { mechanicalPaint } from '../shared/mechanical-paint' import { createPathPointMoveAffordance } from '../shared/path-point-affordance' import { buildLiquidLineFloorplan } from './floorplan' import { buildLiquidLineGeometry } from './geometry' @@ -42,6 +43,8 @@ export const liquidLineDefinition: NodeDefinition = { }), capabilities: { + paint: mechanicalPaint, + slots: () => [{ slotId: 'copper', label: 'Copper' }], selectable: { hitVolume: 'bbox' }, duplicable: true, deletable: true, @@ -50,7 +53,7 @@ export const liquidLineDefinition: NodeDefinition = { parametrics: liquidLineParametrics, geometry: buildLiquidLineGeometry, - geometryKey: (n) => JSON.stringify([n.path, n.diameter]), + geometryKey: (n) => JSON.stringify([n.slots, n.path, n.diameter]), // Open run ends as typed refrigerant ports — directions point outward along // the path tangent so they mate flush onto a service valve. Path coords are diff --git a/packages/nodes/src/liquid-line/geometry.ts b/packages/nodes/src/liquid-line/geometry.ts index 6b7cd42771..a6f7cfdc77 100644 --- a/packages/nodes/src/liquid-line/geometry.ts +++ b/packages/nodes/src/liquid-line/geometry.ts @@ -1,5 +1,8 @@ +import type { GeometryContext } from '@pascal-app/core' +import type { ColorPreset, RenderShading } from '@pascal-app/viewer' import { CylinderGeometry, Group, Mesh, MeshStandardMaterial, SphereGeometry, Vector3 } from 'three' import { INCHES_TO_METERS } from '../duct-segment/geometry' +import { finishMechanicalGeometry } from '../shared/mechanical-finish' import type { LiquidLineNode } from './schema' const RADIAL_SEGMENTS = 16 @@ -41,7 +44,7 @@ function buildRun( * Children are level-local meters; `` owns the node * transform (identity today — the path is absolute within the level). */ -export function buildLiquidLineGeometry(node: LiquidLineNode): Group { +function buildUnpaintedGeometry(node: LiquidLineNode): Group { const group = new Group() if (node.path.length < 2) return group @@ -71,3 +74,23 @@ export function buildLiquidLineGeometry(node: LiquidLineNode): Group { return group } + +export function buildLiquidLineGeometry( + node: LiquidLineNode, + ctx?: GeometryContext, + shading: RenderShading = 'rendered', + textures = true, + colorPreset: ColorPreset = 'clay', + sceneTheme?: string, +): Group { + return finishMechanicalGeometry( + buildUnpaintedGeometry(node), + node.slots, + () => 'copper', + ctx, + shading, + textures, + colorPreset, + sceneTheme, + ) +} diff --git a/packages/nodes/src/ridge-vent/__tests__/schema.test.ts b/packages/nodes/src/ridge-vent/__tests__/schema.test.ts index 270e38b73e..aac37bc7bf 100644 --- a/packages/nodes/src/ridge-vent/__tests__/schema.test.ts +++ b/packages/nodes/src/ridge-vent/__tests__/schema.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test' +import { ridgeVentDefinition } from '../../index' import { RidgeVentNode } from '../schema' describe('RidgeVentNode schema', () => { @@ -47,3 +48,17 @@ describe('RidgeVentNode schema', () => { expect(RidgeVentNode.parse({}).id).not.toBe(RidgeVentNode.parse({}).id) }) }) + +test('ridge vent paint uses a persisted surface slot', () => { + const node = RidgeVentNode.parse({}) + const patch = ridgeVentDefinition.capabilities!.paint!.buildPatch({ + node, + role: 'surface', + material: undefined, + materialPreset: 'library:preset-white', + }) + expect( + RidgeVentNode.parse(JSON.parse(JSON.stringify({ ...node, ...patch }))).slots?.surface, + ).toBe('library:preset-white') + expect(node.slots).toBeUndefined() +}) diff --git a/packages/nodes/src/ridge-vent/geometry.ts b/packages/nodes/src/ridge-vent/geometry.ts index 5b434062c0..db008dd277 100644 --- a/packages/nodes/src/ridge-vent/geometry.ts +++ b/packages/nodes/src/ridge-vent/geometry.ts @@ -6,6 +6,7 @@ import { type RoofSegmentNode, } from '@pascal-app/core' import * as THREE from 'three' +import { finishAccessoryNormals } from '../shared/accessory-normals' import { getRoofTopSurfaceY } from '../shared/roof-surface' const ARC_SEGS = 16 @@ -60,7 +61,7 @@ type RidgeVentEndTaper = { * * `endCaps` closes both ends. Pure: no React, no scene access, no mutation. */ -export function buildRidgeVentGeometry( +function buildUnsmoothedRidgeVentGeometry( node: RidgeVentNode, segment?: RoofSegmentNode, ): THREE.BufferGeometry { @@ -818,3 +819,10 @@ function pushQuad( } for (let i = 0; i < 6; i++) normals.push(nx, ny, nz) } + +export function buildRidgeVentGeometry( + node: RidgeVentNode, + segment?: RoofSegmentNode, +): THREE.BufferGeometry { + return finishAccessoryNormals(buildUnsmoothedRidgeVentGeometry(node, segment)) +} diff --git a/packages/nodes/src/ridge-vent/renderer.tsx b/packages/nodes/src/ridge-vent/renderer.tsx index 9516c3d47d..33731876ba 100644 --- a/packages/nodes/src/ridge-vent/renderer.tsx +++ b/packages/nodes/src/ridge-vent/renderer.tsx @@ -15,7 +15,6 @@ import { import { type ColorPreset, createMaterial, - createSurfaceRoleMaterial, getRoofMaterialArray, resolveMaterialRef, useNodeEvents, @@ -23,6 +22,7 @@ import { } from '@pascal-app/viewer' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' +import { accessoryFinish } from '../shared/accessory-finish' import { getRoofTopSurfaceY } from '../shared/roof-surface' import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip' import { buildRidgeVentGeometry } from './geometry' @@ -188,10 +188,13 @@ const RidgeVentRenderer = ({ node: storeNode }: { node: RidgeVentNode }) => { : null return ( roofMaterials?.[3] ?? - createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) + accessoryFinish('roof', colorPreset, THREE.FrontSide, sceneTheme, shading) ) } + if (!textures) return accessoryFinish('roof', colorPreset, THREE.FrontSide, sceneTheme, shading) + const painted = resolveMaterialRef(node.slots?.surface, sceneMaterials, shading) + if (painted) return painted if (node.material) { return createMaterial(node.material, shading) } @@ -207,6 +210,7 @@ const RidgeVentRenderer = ({ node: storeNode }: { node: RidgeVentNode }) => { colorPreset, sceneTheme, shading, + node.slots, node.material, node.materialPreset, segment, diff --git a/packages/nodes/src/shared/accessory-finish.ts b/packages/nodes/src/shared/accessory-finish.ts new file mode 100644 index 0000000000..8ef5cb2bce --- /dev/null +++ b/packages/nodes/src/shared/accessory-finish.ts @@ -0,0 +1,58 @@ +import type { SurfaceRole } from '@pascal-app/core' +import { + type ColorPreset, + createMaterial, + createSurfaceRoleMaterial, + type RenderShading, + registerMaterialCacheCleanup, + resolveSurfaceColor, +} from '@pascal-app/viewer' +import { FrontSide, type Material, type Side } from 'three' + +export type AccessoryFinish = 'coated' | 'metal' | 'mineral' +const finishes = { + coated: { roughness: 0.38, metalness: 0.12 }, + metal: { roughness: 0.3, metalness: 0.65 }, + mineral: { roughness: 0.88, metalness: 0 }, +} +const glassCache = new Map() +registerMaterialCacheCleanup(() => { + for (const material of glassCache.values()) material.dispose() + glassCache.clear() +}) + +/** Unpainted finishes retain the active theme; explicit paint bypasses this cache. */ +export function accessoryFinish( + role: SurfaceRole, + preset: ColorPreset, + side: Side = FrontSide, + theme?: string, + shading: RenderShading = 'solid', + finish: AccessoryFinish = role === 'wall' ? 'mineral' : 'coated', +): Material { + if (shading === 'solid') return createSurfaceRoleMaterial(role, preset, side, theme) + const color = resolveSurfaceColor(role, preset, theme) + const glass = role === 'glazing' + const material = createMaterial( + { + preset: glass ? 'glass' : 'custom', + properties: { + color, + ...(glass ? { roughness: 0.12, metalness: 0 } : finishes[finish]), + opacity: glass ? 0.25 : 1, + transparent: glass, + side: 'front', + }, + }, + shading, + ) + if (!glass) return material + const existing = glassCache.get(color) + if (existing) return existing + // Glass must not occlude later transparent panes through depth writes. + const glazing = material.clone() + glazing.depthWrite = false + glazing.userData.__pascalCachedMaterial = true + glassCache.set(color, glazing) + return glazing +} diff --git a/packages/nodes/src/shared/accessory-normals.ts b/packages/nodes/src/shared/accessory-normals.ts new file mode 100644 index 0000000000..23e6d0e5c6 --- /dev/null +++ b/packages/nodes/src/shared/accessory-normals.ts @@ -0,0 +1,9 @@ +import type { BufferGeometry } from 'three' +import { toCreasedNormals } from 'three/examples/jsm/utils/BufferGeometryUtils.js' + +/** Smooth shallow curved facets while preserving folded sheet edges and triangle topology. */ +export function finishAccessoryNormals(geometry: T): T { + if (!geometry || geometry.index) return geometry + toCreasedNormals(geometry, Math.PI / 6) + return geometry +} diff --git a/packages/nodes/src/shared/mechanical-finish.ts b/packages/nodes/src/shared/mechanical-finish.ts new file mode 100644 index 0000000000..f71158d8dd --- /dev/null +++ b/packages/nodes/src/shared/mechanical-finish.ts @@ -0,0 +1,78 @@ +import type { GeometryContext } from '@pascal-app/core' +import { + applyWorldScaleBoxUVs, + type ColorPreset, + createMaterial, + type RenderShading, + resolveMaterialRef, +} from '@pascal-app/viewer' +import { + BoxGeometry, + CylinderGeometry, + type Group, + Mesh, + type MeshStandardMaterial, + SphereGeometry, +} from 'three' +import { accessoryFinish } from './accessory-finish' +import { + applyCylinderWorldUvs, + applySphereWorldUvs, + copyUvToSecondaryChannel, +} from './primitive-uv' + +/** Resolve finish/paint on existing meshes; their topology and transforms remain untouched. */ +export function finishMechanicalGeometry( + group: Group, + slots: Record | undefined, + slotFor: (name: string) => string, + ctx?: GeometryContext, + shading: RenderShading = 'rendered', + textures = true, + preset: ColorPreset = 'clay', + theme?: string, +): Group { + const retired = new Set() + group.traverse((object) => { + if (!(object instanceof Mesh) || Array.isArray(object.material)) return + const slot = slotFor(object.name) + object.userData.slotId = slot + const original = object.material as MeshStandardMaterial + const coated = slot === 'cabinet' + const insulation = slot === 'insulation' + object.material = !textures + ? accessoryFinish('furnishing', preset, undefined, theme, shading) + : (resolveMaterialRef(slots?.[slot], ctx?.materials, shading) ?? + createMaterial( + { + preset: 'custom', + properties: { + color: `#${original.color.getHexString()}`, + roughness: insulation ? 0.94 : coated ? 0.38 : Math.min(original.roughness, 0.45), + metalness: insulation ? 0 : coated ? 0.08 : original.metalness, + opacity: 1, + transparent: false, + side: 'front', + }, + }, + shading, + )) + retired.add(original) + const geometry = object.geometry + if (geometry instanceof BoxGeometry) + applyWorldScaleBoxUVs( + geometry, + geometry.parameters.width, + geometry.parameters.height, + geometry.parameters.depth, + ) + else if (geometry instanceof CylinderGeometry) { + const { radiusTop, radiusBottom, height } = geometry.parameters + applyCylinderWorldUvs(geometry, Math.max(radiusTop, radiusBottom), height) + } else if (geometry instanceof SphereGeometry) + applySphereWorldUvs(geometry, geometry.parameters.radius) + copyUvToSecondaryChannel(geometry) + }) + for (const material of retired) material.dispose() + return group +} diff --git a/packages/nodes/src/shared/mechanical-paint.ts b/packages/nodes/src/shared/mechanical-paint.ts new file mode 100644 index 0000000000..af5b39ad6b --- /dev/null +++ b/packages/nodes/src/shared/mechanical-paint.ts @@ -0,0 +1,9 @@ +import { createSlotPaintCapability, previewGeometrySlot } from './slot-paint' + +export const mechanicalPaint = createSlotPaintCapability({ + resolveRole: ({ hitObject }) => { + const slot = hitObject?.userData?.slotId + return typeof slot === 'string' ? slot : null + }, + applyPreview: previewGeometrySlot, +}) diff --git a/packages/nodes/src/skylight/__tests__/schema.test.ts b/packages/nodes/src/skylight/__tests__/schema.test.ts index 1db9ea01c8..61dcf1a812 100644 --- a/packages/nodes/src/skylight/__tests__/schema.test.ts +++ b/packages/nodes/src/skylight/__tests__/schema.test.ts @@ -1,4 +1,6 @@ import { describe, expect, test } from 'bun:test' +import { Group, Mesh, MeshBasicMaterial } from 'three' +import { skylightDefinition } from '../../index' import { SkylightNode } from '../schema' describe('SkylightNode schema', () => { @@ -31,3 +33,46 @@ describe('SkylightNode schema', () => { expect(() => SkylightNode.parse({ slideDirection: 'y' })).toThrow() }) }) + +test('painting the glass preserves the frame and survives serialization', () => { + const node = SkylightNode.parse({ materialPreset: 'library:preset-white' }) + const paint = skylightDefinition.capabilities!.paint! + const patch = paint.buildPatch({ + node, + role: 'glass', + material: undefined, + materialPreset: 'library:preset-glass', + }) + const restored = SkylightNode.parse(JSON.parse(JSON.stringify({ ...node, ...patch }))) + expect(restored.materialPreset).toBe('library:preset-white') + expect(restored.glassMaterialPreset).toBe('library:preset-glass') + expect( + paint.getEffectiveMaterial!({ node: restored, role: 'glass', nodes: {} }).materialPreset, + ).toBe('library:preset-glass') +}) + +test('paint preview restores the glass and leaves its frame untouched', () => { + const frame = new MeshBasicMaterial() + const face = new MeshBasicMaterial() + const root = new Group() + const mesh = new Mesh(undefined, face) + mesh.name = 'skylight-glass' + root.add(mesh) + const frameMesh = new Mesh(undefined, frame) + frameMesh.name = 'skylight-surface' + root.add(frameMesh) + const paint = skylightDefinition.capabilities!.paint! + const node = skylightDefinition.schema.parse({}) + expect(paint.resolveRole({ node, hitObject: mesh, materialIndex: 1 } as never)).toBe('glass') + const restore = paint.applyPreview({ + node, + role: 'glass', + root, + material: undefined, + materialPreset: 'library:preset-white', + }) + expect(mesh.material).not.toBe(face) + expect(frameMesh.material).toBe(frame) + restore?.() + expect(mesh.material).toEqual(face) +}) diff --git a/packages/nodes/src/skylight/definition.ts b/packages/nodes/src/skylight/definition.ts index 7f4affe46a..c5bc50bae4 100644 --- a/packages/nodes/src/skylight/definition.ts +++ b/packages/nodes/src/skylight/definition.ts @@ -12,6 +12,7 @@ import { isOperableSkylightNode, toggleSkylightOpenState, } from './interaction' +import { skylightPaint } from './paint' import { skylightParametrics } from './parametrics' import { buildSkylightRoofCut } from './roof-cut' import { SkylightNode } from './schema' @@ -234,6 +235,7 @@ export const skylightDefinition: NodeDefinition = { }, capabilities: { + paint: skylightPaint, exterior: true, selectable: { hitVolume: 'bbox' }, duplicable: true, diff --git a/packages/nodes/src/skylight/paint.ts b/packages/nodes/src/skylight/paint.ts new file mode 100644 index 0000000000..abbdb341d7 --- /dev/null +++ b/packages/nodes/src/skylight/paint.ts @@ -0,0 +1,36 @@ +import type { PaintCapability, SkylightNode } from '@pascal-app/core' +import type { Mesh, Object3D } from 'three' +import { buildSlotPreviewMaterial } from '../shared/slot-paint' +import { swapPreviewMaterial } from '../shared/swap-preview-material' + +export const skylightPaint: PaintCapability = { + resolveRole: ({ hitObject }) => + (hitObject as Mesh | undefined)?.name === 'skylight-glass' ? 'glass' : 'frame', + roleLabel: (_node, role) => (role === 'frame' ? 'Frame' : 'Glass'), + buildPatch: ({ role, material, materialPreset }) => + role === 'glass' + ? { glassMaterial: material, glassMaterialPreset: materialPreset } + : { material, materialPreset }, + getEffectiveMaterial: ({ node, role }) => { + const surface = node as SkylightNode + return role === 'glass' + ? { material: surface.glassMaterial, materialPreset: surface.glassMaterialPreset } + : { material: surface.material, materialPreset: surface.materialPreset } + }, + applyPreview: ({ role, material, materialPreset, root }) => { + const preview = buildSlotPreviewMaterial(material, materialPreset) + if (!preview) return null + const restores: Array<() => void> = [] + ;(root as Object3D).traverse((object) => { + const mesh = object as Mesh + if (!mesh.isMesh) return + if (mesh.name !== (role === 'glass' ? 'skylight-glass' : 'skylight-surface')) return + restores.push(swapPreviewMaterial(mesh, preview)) + }) + return restores.length + ? () => { + for (const restore of restores) restore() + } + : null + }, +} diff --git a/packages/nodes/src/skylight/renderer.tsx b/packages/nodes/src/skylight/renderer.tsx index f1f11eb119..8be8f16af9 100644 --- a/packages/nodes/src/skylight/renderer.tsx +++ b/packages/nodes/src/skylight/renderer.tsx @@ -15,13 +15,13 @@ import { type ColorPreset, createMaterial, createMaterialFromPresetRef, - createSurfaceRoleMaterial, getRoofOuterSurfaceFrameAtPoint, useNodeEvents, useViewer, } from '@pascal-app/viewer' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' +import { accessoryFinish } from '../shared/accessory-finish' import { surfaceQuatFromNormal } from '../shared/roof-surface' import { TrimClippedMesh, useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip' import { buildFrameGeometry } from './frame-csg' @@ -711,7 +711,7 @@ const SkylightRenderer = ({ node: storeNode }: { node: SkylightNode }) => { // Untextured frame (and everything in textures-off mode) takes the // themed 'joinery' role colour; explicit paint shows when textures on. if (!textures || (!node.material && !node.materialPreset)) { - return createSurfaceRoleMaterial('joinery', colorPreset, undefined, sceneTheme) + return accessoryFinish('joinery', colorPreset, undefined, sceneTheme, shading) } if (node.material) return createMaterial(node.material, shading) return createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultFrameMaterial @@ -729,7 +729,7 @@ const SkylightRenderer = ({ node: storeNode }: { node: SkylightNode }) => { // Untextured glass (and textures-off mode) takes the themed 'glazing' // role material from the shared cache, so it must not be mutated. if (!textures || (!node.glassMaterial && !node.glassMaterialPreset)) { - return createSurfaceRoleMaterial('glazing', colorPreset, undefined, sceneTheme) + return accessoryFinish('glazing', colorPreset, undefined, sceneTheme, shading) } const mat = node.glassMaterial ? createMaterial(node.glassMaterial, shading) diff --git a/packages/nodes/src/solar-panel/__tests__/schema.test.ts b/packages/nodes/src/solar-panel/__tests__/schema.test.ts index c77cbca7ba..ee046193d9 100644 --- a/packages/nodes/src/solar-panel/__tests__/schema.test.ts +++ b/packages/nodes/src/solar-panel/__tests__/schema.test.ts @@ -1,4 +1,6 @@ import { describe, expect, test } from 'bun:test' +import { Group, Mesh, MeshBasicMaterial } from 'three' +import { solarPanelDefinition } from '../../index' import { SolarPanelNode } from '../schema' describe('SolarPanelNode schema', () => { @@ -42,3 +44,46 @@ describe('SolarPanelNode schema', () => { expect(parsed.surfaceNormal).toEqual([0.2, 0.95, -0.18]) }) }) + +test('painting the panel preserves the frame and survives serialization', () => { + const node = SolarPanelNode.parse({ materialPreset: 'library:preset-white' }) + const paint = solarPanelDefinition.capabilities!.paint! + const patch = paint.buildPatch({ + node, + role: 'panel', + material: undefined, + materialPreset: 'library:preset-glass', + }) + const restored = SolarPanelNode.parse(JSON.parse(JSON.stringify({ ...node, ...patch }))) + expect(restored.materialPreset).toBe('library:preset-white') + expect(restored.panelMaterialPreset).toBe('library:preset-glass') + expect( + paint.getEffectiveMaterial!({ node: restored, role: 'panel', nodes: {} }).materialPreset, + ).toBe('library:preset-glass') +}) + +test('paint preview restores the panel and leaves its frame untouched', () => { + const frame = new MeshBasicMaterial() + const face = new MeshBasicMaterial() + const root = new Group() + const mesh = new Mesh(undefined, [frame, face]) + mesh.name = 'solar-panel-surface' + root.add(mesh) + const frameMesh = new Mesh(undefined, frame) + frameMesh.name = 'skylight-surface' + root.add(frameMesh) + const paint = solarPanelDefinition.capabilities!.paint! + const node = solarPanelDefinition.schema.parse({}) + expect(paint.resolveRole({ node, hitObject: mesh, materialIndex: 1 } as never)).toBe('panel') + const restore = paint.applyPreview({ + node, + role: 'panel', + root, + material: undefined, + materialPreset: 'library:preset-white', + }) + expect((mesh.material as import('three').Material[])[1]).not.toBe(face) + expect(frameMesh.material).toBe(frame) + restore?.() + expect(mesh.material).toEqual([frame, face]) +}) diff --git a/packages/nodes/src/solar-panel/definition.ts b/packages/nodes/src/solar-panel/definition.ts index 2f647c3a29..48787501c9 100644 --- a/packages/nodes/src/solar-panel/definition.ts +++ b/packages/nodes/src/solar-panel/definition.ts @@ -5,6 +5,7 @@ import { type SolarPanelNode as SolarPanelNodeType, } from '@pascal-app/core' import { buildSolarPanelFloorplan } from './floorplan' +import { solarPanelPaint } from './paint' import { solarPanelParametrics } from './parametrics' import { SolarPanelNode } from './schema' @@ -246,6 +247,7 @@ export const solarPanelDefinition: NodeDefinition = { }, capabilities: { + paint: solarPanelPaint, exterior: true, selectable: { hitVolume: 'bbox' }, duplicable: true, diff --git a/packages/nodes/src/solar-panel/geometry.ts b/packages/nodes/src/solar-panel/geometry.ts index d1efe315c4..7e0eb36fe7 100644 --- a/packages/nodes/src/solar-panel/geometry.ts +++ b/packages/nodes/src/solar-panel/geometry.ts @@ -1,4 +1,5 @@ import type { SolarPanelNode } from '@pascal-app/core' +import { applyWorldScaleBoxUVs } from '@pascal-app/viewer' import * as THREE from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { MeshStandardNodeMaterial } from 'three/webgpu' @@ -6,8 +7,8 @@ import { MeshStandardNodeMaterial } from 'three/webgpu' const SOLAR_CELL_SIZE_M = 0.16 // Procedurally generated cell texture used by the default panel material. -// Drawn once into an offscreen canvas, wrapped, and tiled per cell by the -// stretched UVs assigned in `buildSolarPanelGeometry`. +// Drawn once into an offscreen canvas and repeated at cell scale. Geometry +// keeps metre UVs so painted materials use the same scale as other surfaces. export function createSolarPanelTexture(): THREE.CanvasTexture | null { if (typeof document === 'undefined') return null @@ -18,10 +19,10 @@ export function createSolarPanelTexture(): THREE.CanvasTexture | null { const ctx = canvas.getContext('2d') if (!ctx) return null - ctx.fillStyle = '#dde3ec' + ctx.fillStyle = '#18202b' ctx.fillRect(0, 0, size, size) - const pad = size * 0.04 + const pad = size * 0.018 const x = pad const y = pad const cellW = size - pad * 2 @@ -40,8 +41,8 @@ export function createSolarPanelTexture(): THREE.CanvasTexture | null { ctx.closePath() const grad = ctx.createLinearGradient(x, y, x + cellW, y + cellH) - grad.addColorStop(0, '#0f1b3a') - grad.addColorStop(1, '#162546') + grad.addColorStop(0, '#101b29') + grad.addColorStop(1, '#1b2c3e') ctx.fillStyle = grad ctx.fill() @@ -73,6 +74,7 @@ export function createSolarPanelTexture(): THREE.CanvasTexture | null { tex.colorSpace = THREE.SRGBColorSpace tex.wrapS = THREE.RepeatWrapping tex.wrapT = THREE.RepeatWrapping + tex.repeat.set(1 / SOLAR_CELL_SIZE_M, 1 / SOLAR_CELL_SIZE_M) tex.anisotropy = 8 tex.needsUpdate = true return tex @@ -86,8 +88,8 @@ export function getDefaultPanelMaterial(): THREE.Material { // MRT error that fires when MeshStandardMaterial is used in the WebGPU pass. const mat = new MeshStandardNodeMaterial({ color: new THREE.Color(map ? 0xffffff : 0x0c0c1f), - roughness: 0.22, - metalness: 0.35, + roughness: 0.16, + metalness: 0.08, }) if (map) mat.map = map _defaultPanelMaterial = mat @@ -145,15 +147,7 @@ export function buildSolarPanelGeometry(node: SolarPanelNode): THREE.BufferGeome if (glassW > 0 && glassH > 0) { const glass = new THREE.BoxGeometry(glassW, frameDepth * 0.6, glassH) glass.translate(cx, y + frameDepth * 0.1, cz) - // Stretch the cell UVs so a tiled cell texture reads correctly - // regardless of the panel's aspect ratio. - const cellsU = Math.max(1, Math.round(glassW / SOLAR_CELL_SIZE_M)) - const cellsV = Math.max(1, Math.round(glassH / SOLAR_CELL_SIZE_M)) - const uv = glass.getAttribute('uv') as THREE.BufferAttribute - for (let i = 0; i < uv.count; i++) { - uv.setXY(i, uv.getX(i) * cellsU, uv.getY(i) * cellsV) - } - uv.needsUpdate = true + applyWorldScaleBoxUVs(glass, glassW, frameDepth * 0.6, glassH) panelGeos.push(glass) } @@ -209,6 +203,12 @@ export function buildSolarPanelGeometry(node: SolarPanelNode): THREE.BufferGeome if (frameGeos.length === 0) return null + for (const geometry of frameGeos) { + if (geometry instanceof THREE.BoxGeometry) { + const { width, height, depth } = geometry.parameters + applyWorldScaleBoxUVs(geometry, width, height, depth) + } + } const frameMerged = mergeGeometries(frameGeos, false) const panelMerged = panelGeos.length > 0 ? mergeGeometries(panelGeos, false) : null for (const g of frameGeos) g.dispose() diff --git a/packages/nodes/src/solar-panel/paint.ts b/packages/nodes/src/solar-panel/paint.ts new file mode 100644 index 0000000000..1c8723ff72 --- /dev/null +++ b/packages/nodes/src/solar-panel/paint.ts @@ -0,0 +1,37 @@ +import type { PaintCapability, SolarPanelNode } from '@pascal-app/core' +import type { Mesh, Object3D } from 'three' +import { buildSlotPreviewMaterial } from '../shared/slot-paint' +import { swapPreviewMaterial } from '../shared/swap-preview-material' + +export const solarPanelPaint: PaintCapability = { + resolveRole: ({ materialIndex }) => (materialIndex === 1 ? 'panel' : 'frame'), + roleLabel: (_node, role) => (role === 'frame' ? 'Frame' : 'Panel'), + buildPatch: ({ role, material, materialPreset }) => + role === 'panel' + ? { panelMaterial: material, panelMaterialPreset: materialPreset } + : { material, materialPreset }, + getEffectiveMaterial: ({ node, role }) => { + const surface = node as SolarPanelNode + return role === 'panel' + ? { material: surface.panelMaterial, materialPreset: surface.panelMaterialPreset } + : { material: surface.material, materialPreset: surface.materialPreset } + }, + applyPreview: ({ role, material, materialPreset, root }) => { + const preview = buildSlotPreviewMaterial(material, materialPreset) + if (!preview) return null + const restores: Array<() => void> = [] + ;(root as Object3D).traverse((object) => { + const mesh = object as Mesh + if (!mesh.isMesh) return + if (mesh.name !== 'solar-panel-surface' || !Array.isArray(mesh.material)) return + const materials = [...mesh.material] + materials[role === 'panel' ? 1 : 0] = preview + restores.push(swapPreviewMaterial(mesh, materials)) + }) + return restores.length + ? () => { + for (const restore of restores) restore() + } + : null + }, +} diff --git a/packages/nodes/src/solar-panel/renderer.tsx b/packages/nodes/src/solar-panel/renderer.tsx index afbb227d20..dcc11dad6b 100644 --- a/packages/nodes/src/solar-panel/renderer.tsx +++ b/packages/nodes/src/solar-panel/renderer.tsx @@ -12,7 +12,6 @@ import { type ColorPreset, createMaterial, createMaterialFromPresetRef, - createSurfaceRoleMaterial, getRoofOuterSurfaceFrameAtPoint, useNodeEvents, useViewer, @@ -20,6 +19,7 @@ import { import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { MeshStandardNodeMaterial } from 'three/webgpu' +import { accessoryFinish } from '../shared/accessory-finish' import { surfaceQuatFromNormal } from '../shared/roof-surface' import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip' import { buildSolarPanelGeometry, getDefaultPanelMaterial } from './geometry' @@ -124,7 +124,7 @@ const SolarPanelRenderer = ({ node: storeNode }: { node: SolarPanelNode }) => { // left untouched in both texture modes. const frameMaterial = useMemo(() => { if (!textures || (!node.material && !node.materialPreset)) { - return createSurfaceRoleMaterial('roof', colorPreset, undefined, sceneTheme) + return accessoryFinish('roof', colorPreset, undefined, sceneTheme, shading) } if (node.material) return createMaterial(node.material, shading) return createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultFrameMaterial diff --git a/packages/nodes/src/turbine-vent/geometry.ts b/packages/nodes/src/turbine-vent/geometry.ts index 6be625617c..5dd0e95790 100644 --- a/packages/nodes/src/turbine-vent/geometry.ts +++ b/packages/nodes/src/turbine-vent/geometry.ts @@ -1,6 +1,7 @@ import type { TurbineVentNode } from '@pascal-app/core' import * as THREE from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' +import { finishAccessoryNormals } from '../shared/accessory-normals' import { copyUvToSecondaryChannel, cumulativeProfileDistances, @@ -57,7 +58,7 @@ function resolveDims(node: TurbineVentNode): Dims { // ─── Public builders ───────────────────────────────────────────────── -export function buildTurbineVentBase(node: TurbineVentNode): THREE.BufferGeometry { +function buildUnsmoothedTurbineVentBase(node: TurbineVentNode): THREE.BufferGeometry { const d = resolveDims(node) const p: number[] = [] const n: number[] = [] @@ -76,7 +77,7 @@ export function buildTurbineVentBase(node: TurbineVentNode): THREE.BufferGeometr return toGeometry(p, n, uv) } -export function buildTurbineVentHead(node: TurbineVentNode): THREE.BufferGeometry { +function buildUnsmoothedTurbineVentHead(node: TurbineVentNode): THREE.BufferGeometry { const d = resolveDims(node) const p: number[] = [] const n: number[] = [] @@ -468,3 +469,11 @@ function toGeometry(positions: number[], normals: number[], uvs: number[]): THRE geo.computeBoundingSphere() return geo } + +export function buildTurbineVentBase(node: TurbineVentNode): THREE.BufferGeometry { + return finishAccessoryNormals(buildUnsmoothedTurbineVentBase(node)) +} + +export function buildTurbineVentHead(node: TurbineVentNode): THREE.BufferGeometry { + return finishAccessoryNormals(buildUnsmoothedTurbineVentHead(node)) +} diff --git a/packages/nodes/src/turbine-vent/renderer.tsx b/packages/nodes/src/turbine-vent/renderer.tsx index 6288fc6653..2ae7911009 100644 --- a/packages/nodes/src/turbine-vent/renderer.tsx +++ b/packages/nodes/src/turbine-vent/renderer.tsx @@ -12,7 +12,6 @@ import { type ColorPreset, createMaterial, createMaterialFromPresetRef, - createSurfaceRoleMaterial, getRoofOuterSurfaceFrameAtPoint, resolveMaterialRef, useNodeEvents, @@ -21,6 +20,7 @@ import { import { useFrame } from '@react-three/fiber' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' +import { accessoryFinish } from '../shared/accessory-finish' import { surfaceQuatFromNormal } from '../shared/roof-surface' import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip' import { buildTurbineVentBase, buildTurbineVentHead } from './geometry' @@ -106,7 +106,14 @@ const TurbineVentRenderer = ({ node: storeNode }: { node: TurbineVentNode }) => }, [segment, node.position[0], node.position[2]]) const material = useMemo(() => { - const roleDefault = createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) + const roleDefault = accessoryFinish( + 'roof', + colorPreset, + THREE.FrontSide, + sceneTheme, + shading, + 'metal', + ) const resolve = (role: 'base' | 'head') => { if (!textures) return roleDefault const slotMaterial = resolveMaterialRef(node.slots?.[role], sceneMaterials, shading) diff --git a/wiki/design/pr-1125-verification/README.md b/wiki/design/pr-1125-verification/README.md index 2810e0333a..77cf5b4a3f 100644 --- a/wiki/design/pr-1125-verification/README.md +++ b/wiki/design/pr-1125-verification/README.md @@ -26,3 +26,18 @@ The legacy fixture is synthetic: it represents the pre-change schema by omitting ## Legacy HVAC load ![Legacy furnace and connected refrigerant run](legacy.webp) + +## Visual finishes and paint (2026-10-23) + +The follow-up uses theme-aware rendered finishes, crease-preserving curved normals, refined solar cell artwork, and existing material slots. It adds no geometry or render passes. Explicit paint overrides default finishes. + +Skylight frame/glass and solar frame/panel painting use their existing material fields. HVAC, refrigerant lines, and ridge vents gain optional `slots` storage; old scenes are not rewritten and the field is populated only when painted. HVAC separates cabinet, mechanical parts, and service connections. Suction-line copper and insulation paint independently. + +Validation: + +- `bun run ci` passed, including 5,113 node tests (one skipped); the two subsequent targeted glass/panel preview tests also passed. +- A temporary comparison against commit `19e934572` checked positions, indices, and triangle counts for 21 configurations across 12 changed builders. All were identical. Chimney, dormer, and skylight topology was not edited. +- In `bun dev`, a separate test scene rendered the roof-accessory gallery. Painting the HVAC cabinet, skylight glass, and solar panel face left their other surfaces unchanged and survived browser reload. +- GPU frame time has not been benchmarked. Materials and normals change shading work even when triangle counts are unchanged. + +![Painted cabinet, skylight glass and solar faces after reload](paint-persistence.webp) diff --git a/wiki/design/pr-1125-verification/paint-persistence.webp b/wiki/design/pr-1125-verification/paint-persistence.webp new file mode 100644 index 0000000000000000000000000000000000000000..b2258265159034887b60a3a61c4a6e7bf9c8a2ca GIT binary patch literal 39732 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%0AVM|7_s;J7uVM0^6C&SPB+z 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

`>3=vZ0tYF|F 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 h0B6Et>N9^h>W$_WC1)rM@|N^@)zPkz^45294!s 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 Ai2wiq literal 0 HcmV?d00001 From b7dae4c4b9387888301ed71de4447d2196d83c1b Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 23 Oct 2026 12:00:26 +0530 Subject: [PATCH 6/6] Classify architect request metadata in reference inventory --- packages/core/src/contracts/reference-inventory.ts | 4 ++++ packages/nodes/src/box-vent/tool.test.tsx | 1 + 2 files changed, 5 insertions(+) diff --git a/packages/core/src/contracts/reference-inventory.ts b/packages/core/src/contracts/reference-inventory.ts index ab6a5f66c7..a7bc7fd029 100644 --- a/packages/core/src/contracts/reference-inventory.ts +++ b/packages/core/src/contracts/reference-inventory.ts @@ -958,6 +958,10 @@ const described = (reason: string, paths: readonly string[]) => /** Metadata keys found in editor sources that are not references. */ export const METADATA_NON_REFERENCES: readonly { path: string; reason: string }[] = [ + ...described( + 'Architect API request metadata: hosted project identity and credit budget, not scene-node references.', + ['project_id', 'pascal_max_credits'], + ), { path: 'metadata.roomBoundary', reason: 'Boolean opt-out from room boundary detection.' }, ...described( "author_scene's provenance: the payload's own ids on walls and openings, a room's seed, a level's elevation, a slab's finish, a wall's colour, and on the building the stored payload, the report and when it was written; none of it names a node.", diff --git a/packages/nodes/src/box-vent/tool.test.tsx b/packages/nodes/src/box-vent/tool.test.tsx index 1cb58e8b0a..ee958546d2 100644 --- a/packages/nodes/src/box-vent/tool.test.tsx +++ b/packages/nodes/src/box-vent/tool.test.tsx @@ -79,6 +79,7 @@ test('box-vent placement matches its preview, honors grid mode and blocks unsupp if (preview?.type !== 'box-vent') throw new Error('Missing box-vent preview') await act(async () => emitter.emit('roof:click', event)) const placed = Object.values(useScene.getState().nodes).find((node) => node.type === 'box-vent') + expect(useScene.getState().dirtyNodes.has(segment.id)).toBe(true) expect(placed).toBeDefined() if (placed?.type !== 'box-vent') throw new Error('Missing box vent') expect(preview.position).toEqual(placed.position)

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 S7XjNdpi3qI000000002c$qtkN literal 0 HcmV?d00001 diff --git a/apps/editor/public/icons/skylight/lantern.webp b/apps/editor/public/icons/skylight/lantern.webp new file mode 100644 index 0000000000000000000000000000000000000000..c98684dc64f3de6ae451ccced891c9e047cfef71 GIT binary patch literal 11678 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 literal 0 HcmV?d00001 diff --git a/apps/editor/public/icons/solar-panel/frameless.webp b/apps/editor/public/icons/solar-panel/frameless.webp new file mode 100644 index 0000000000000000000000000000000000000000..72706277b5261879f18120afcf7af92f69c93a04 GIT binary patch literal 9740 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%0D6~4;RIE7C*VIDItMym!Jp4KmMbqg zo>>2%_bc5s{81V2sm;p{JzPegm0{n!2{ zEw`@w&HT?@f4=vR@{dq2$A6f4vweqQ2GiyR>%ZE+>-x1!JM67Me=;LLMr~W+rng8SDv*};`U+caBKbwD2|9}1`_q+Vh&400<+W-6B>L0I!a~%gR z6gH)75_;$z3$*FE3Vn`)mkJwFwh29RZ+8(}ALaJ%`k$zvq_HM->lR*sv3g+zAY-8A z!c>1?rS9i*p{+o=eYBdTR?$SVo%`!PYVtp46V86-`R5s;<%Wjrq{$Y|Awz09j>@d^ z4{0jgQV`uFKk(Gh#JCMj3#4D#RM`RmTA@d&i4WjU{_qy2n4g{0rN?q(W*X++@L$X? zDHe0K%p!h zhPxXz#Cp_IP)-s3QeB)DsKV#EsAv(s&>$I#r?(dvjk7h7-926R4xx@_Ffn4^$mp$P zUo26pJT)_00k0Z;L52=3sKdoog*UBd>Z(FO&S|vtO}5X_9LlfR*a3+fJ*%Xy03#gZ zkZJz{%<;djwP$6M)~Rdr&?!39CEJUC=2j_Y$2zkk1t46>|2T5W9CFPX)P(l;Ag+-r$kaA`U9Si4e@Gk0wsC#XNYyJt*BQBv6 zUFSDjx%o#O^C}0SEd>=2vO{?+r`RoG?(m;eS6A88Qjzke-uv%|c8_g*+*}S?LmN{l zrNV~Pt$-sbxNjroYyt{AR@T^2(L-M$PqEN)%l|3z;X`Ux!6&YP(7R5Xkf+$_IdG5w z0RI2(SwH{)00EoeZw`j0A4LUT3ZAMXfcY`SMxoW=!`CMI;)T8gn=w2cueVQ~B?%O>gVIdGynC<%B;E?G&cEWHh9>Y6!+wBx-Lx>Sd^HDY|rtET}%CR}VPqPk^YV5?AgHl?g}^wu>$ z1Zu^k2{Gx_2-9arLzACCHUglv!zc-(yX)W*|LyW_I;z$qj(n4?w)Gn`jTT#586wgD zo}hP{2Po-`qg=GFfC9CEYj#6cVF&2@-W-DKl%tftQk76?65Cm@Ciu-={6``1|8+hn zxbE_^hELg@xUcQIq*ivW=1;Wu|0r4fHN)(1{aBLe&=q>A174AQX*mCz`6xZl37&xx zwPDuHYQUlKHSD~F+ZfXBvb5OVaU%&xI=8=0R|(uhg_JW-)Gkc_0jJ$tM?xQ4PZ%ID zziYby-&`P7kJR53es2#rlA}|;EK%UU91!BMH@8MU5-k1ckI68m6%HusY7+xBD9j%D zNdT>X{jlUQcaAfyE9IKV!(>Vg4)~r%`}a{{8by2p%NRaR@XK1x+>kG*x*>weAsq6K zmU5&4nzH3CBvAw@kMsTjLmALfq*xBSv$UKatYz<`(+C;&peCV1Q(!sZyQl6luToIe zjb{AFKZ`6~j1#XCsnro2ft)Vl#jsc5fVJv13XjJV2}+}_zD=G^7GwX%9vk9x2e615|v4lrP)z=H{MW zav9t=#SX5`=SJ;v|p(VTtVH~U!23&0=E2SQaFCs6hHP?;f zYt^6OPN^?!NW-f00@Zq@FTCTu$1GV7E6%JT4;KO#?*pnyk#~!!u^PO?HSDWOt-s-= zQJPriSiSPm6vwgRO7H#7vT0p&3}mnPF9#_wk5oI5x*D?xuk>p0emX~ACt`rq*l#xP zSY*vSTxW`t6-{IWs9O}*cf*+Sf4-^38=CdnQYZRb*W5mUu7mDvb3`lg2S4B|cd*_{ zPXs~R*K+sCQp3Dt;TIUcRTYT+h42rt9&tLdGTRpJ;p6A!1wH$S98`82IBiEES5N*M znDv;4t8RYDu{s|U3$4lsxvIBYQEqEheHt*qd^wJ_q9ZK&hd5QQ{A#r=c0K3>T}E1# z9?alkHIu>TE5Q#x#24Y{XwIsN0E^;n}{9fVouL6=Xoq+Xv zVR7cnxnmD%HFqfTe}p@U1zLphD9_i;WT2c<9)?mBHC`6G+Uz|>LsqYrfb{<0Ma1%% zCFLWdk7wY1N0oe+x`JzOYq{aBY#4A2TGRKvaeB@cu_VwB9fkzBCnNVUVm|8ef*Ei?dr>yXX5jDh`<_rl_VqCE zB+|^j$SZE0bL5~0KLaIHWIMc@J@c$_t9rtfUuU9ScC>%Hkr>JXSAD%P{aB7XT60sh zr=U%lF<&9CBcTQe`3wd=Ge9nRJ3_e0nf+W=1@v;Mv7GKiJ=nMNmQerjAcJWp0hfPS zrU8rdTE56-oWY$q`058wlIKq6%-0|(_&}p*iPPfH>jkV60@+h8KQaOYcbrmQ+_M#* zNT?R8Icvh+I7n{e$}<;YC-P4+NhU0wq_m0nT)K&s(O2;{rHO^40w{}MUF9Unv?i)1 zCpmgyT|#V@8B#miD!4fo*gS~ASa;pzLP~mhPeJI{R#I!Cdwp}CJHk#3YtS6gf8Aq9 zKk-YEFTn4?Xfx~hQi99!oFm!2u+y$ZsBFLdyx2KTBG{w-VDHT6feonPlDq_6QkGWi zpuJbHlD_cYm*>$LtIA&4^&;YyWHui}Hnp~9zWKk44I1iLeBuNu^`$#0j~UF>FE*%U zfF^FVCdy+)1vSmin}E_Mu$-*RhXQjLPo1=daW#2?&va2=KR!Ag5Wf-VoOT80FqTdL zc>y5~z>Eqn^b<{A(PcC1R>s?Hz^$f0#CIldBOYGcRpOpKmMuzuL0=KaUh5D%fMUoO zr4|oEUdR_-gtS_CKO$G#i7* z?NnQX}GxEVKXBSD|v*2<3 zly8(i#C`IQbMa+iqrdFRc~RyWPFVl#yZC_p`{e^oIrls9CNUd1&oy4P+BiOyoRtwSh{~FYRagTGFQx#uNtG!g#y?)ts?_0&oJNv-HNg z!|a1LulK${%dPP<>yzb$bpq;k&FiEnL$4o|1@t;!PcbFvP3UE+dyawuwxj0tW{fd) zQgg>_obnh1X|Yj7ximh1yELSPj^4m|kM<+@Qm@4}z<<+yo9qAb%K!g#bN}4|zyG=0d(FO+U#f9{t0i@my#B z#L62236rVMx1W$&Yq^Lg^)~Uevj`yR%a6z`wlTC{O0~eAMbrPu4F0_<+S6`11d?$Q z)${>J`N_E+A%Ie$^`QKGY#oJEem;hVoK*`qET}*QUy)=_2okX!ab7G#B!L9j*eD?Q zVYt6W{2c2Z5P@8ctX!ogg+H)aoT;P47B0}f!`AS>NmcsW5d>zQ~6p^?S07 z(N>BdV;+i2?-7FADq51QypIKZ3kY_`HGCL>x+wpv0<`GyFJsQmv;hQEuAMOdL?WLV zu8sW5Bp&ER za~4*-2~Cduz<+VBE%JRfAIK|$ymw#)hF~S@aK23)fUUD#n=~EIdf-Q$v#*_VN2MhY zM9I?H`1U=u4o}wA=)eXO3H#ak-k{CFALT{pwSN6*q4i(yhr2B=63e0cc60KGe5U8m zkbz|3*%wzD|6X(Ge9bNo^HhHQQ(Kl)3a*eAq4Vl5^70*6bHA`fLP+I}I{^ZLzvJL| z{_F0XK7;ptu97TOz9%6%>jAVy101`jOq*D1 zMoxu`@_`@8S7Ww;wt1VKFK&EnO-ZwND8GNp(Q%|sq9%oe`J(!Hj%oxcOOV&_jD+*M zj_eZ?teaad{S3GWyLVWV+p;a*Z0`%>rX>!`b_iKS=Ml&8^f?6X&YS`ud;8)U3;e;V zN=>37u<00$Sw!az@s;UwCx28m5S6qba-}+1zZ+_sd~y&Cx4(7~1C%-?;X%Jf*qWw^ zyziDySS(OZ&5!@Glz=74gCX6~p0o=CdIwP~s+R)r;fVgjyxKY_VV`tk{w16I|kC0D37&kEA{( zL_vCd6nqJQ6_^YimgS)KPF?AWqh0$&*(0z@4nu0e=+MTkSY~wZ=XaYy+L_ZH!q@b! zdYl>03+3$l#(b=zFn+a(0YPcgrR)@48E_uGGrXqLuG00{7Cj;b?dDg&YYh~iMvsJ8 zPsO`)(3ju?JRsaE01DtOG9!$AZ+)pK2;sW%CBlUD-Q!_h`QE)0=560yl(n0Lg3xYM zgkDMA>cZFZ_+syG*B3v+PEbnMTgUt`@OBw(?y7vR?hAd9q1D;WXHbG+#3EyLMY{=S z>!q95Y(j?izq(g_SU&*23 zi}F%^HVfUO3$xUz+tU6mrp=tnR;5mtN_l(R6Kv7Rt8+X)%DFi6I@kRbI=WXo+m3|^ z@_II{WC0C$*;oP#BWyB&udg3eG#3^3JEyaep!~RcJf~W@jaUXgB6#1&fhJOj_u+26 zy4KO1s|Y&(aOk=VzY6b)eHU_B0Hm1dYL=Z50jA z0!NKAln`8aghWgW zNCqjc{H?Uzj;rxUk-adg?OuFw!{!U;3EYoW6wAybIu7Ab76$h8 zLdkp&haZR!a&3G*J6cUhslOx7>i^yyVf%0@cYUZ2T3R&89XWt9%+*%s_1PfiaC+ZP z@R|SG?t7LMRZ3LjCeE5{Ee8&m&?Ykc9-n9JOYiHb00g1$fd!>kjv_(5Vx-`qoTp4B zwrOka^5|cW^vERXye#?9@Sjwl+7qDM$&ckK<&g+# zvz8zCb*CsbRISlSInE4T@_&T?faVyQnLE4O=g0gvm>u;(!eaCex9*^dz*6}wQ2mNLS%{qldey@AQSH&7XPM9fDqACA51%%;4 cyNQD9LomzMHwdnMXD-;D00000000000EnaB*#H0l literal 0 HcmV?d00001 diff --git a/apps/editor/public/icons/solar-panel/residential.webp b/apps/editor/public/icons/solar-panel/residential.webp new file mode 100644 index 0000000000000000000000000000000000000000..b84a043dab7f38cfa25aaf85b2b9f29ea08f943a GIT binary patch literal 10078 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 CTYQ!P literal 0 HcmV?d00001 diff --git a/apps/editor/public/icons/turbine-vent/globe.webp b/apps/editor/public/icons/turbine-vent/globe.webp new file mode 100644 index 0000000000000000000000000000000000000000..6f4b9932e05716cb899effd371f478f8b08e42a6 GIT binary patch literal 13148 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�~58tF_Gha{UNq*JYuMc+CR8f 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 = { ports: ({ path }) => { const start = path[0] const end = path.at(-1) - return start && end && path.length > 1 ? [water('start', start), water('end', end)] : [] + return start && end && path.length > 1 + ? [ + { + ...water('start', start), + direction: start.map((value, i) => value - path[1]![i]!) as [number, number, number], + }, + { + ...water('end', end), + direction: end.map((value, i) => value - path.at(-2)![i]!) as [number, number, number], + }, + ] + : [] }, } const benchDef: NodeDefinition = { diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index f7f51ae749..6888d92a51 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -330,6 +330,8 @@ export type NodePort = { * face at roll 0, height the vertical one. */ width?: number height?: number + /** Physical connection subtype, such as suction or liquid refrigerant. */ + connectionType?: string /** Ports sharing a group are one flow path of one system (F6). Unread until F6. */ group?: string /** Membership label of this port's group; labels, never connects (F6). Unread until F6. */ diff --git a/packages/core/src/schema/host-children.test.ts b/packages/core/src/schema/host-children.test.ts index f6263387e1..f61e90e0f6 100644 --- a/packages/core/src/schema/host-children.test.ts +++ b/packages/core/src/schema/host-children.test.ts @@ -48,7 +48,19 @@ test('frozen pre-slice corpus covers every kind predating separators', () => { test.each(baseline)( 'pre-slice $type parses, saves and loads without changing existing fields', (saved) => { - const expected = saved.type === 'column' ? { ...saved, children: [] } : saved + const expected = + saved.type === 'column' + ? { ...saved, children: [] } + : saved.type === 'hvac-equipment' + ? { + ...saved, + supplyFace: 'top', + returnFace: 'left', + serviceFace: 'right', + suctionDiameter: 0.875, + liquidDiameter: 0.375, + } + : saved const parsedExpected = saved.type === 'zone' ? { ...expected, boundarySeparatorIds: [], holes: [] } diff --git a/packages/core/src/schema/nodes/box-vent.ts b/packages/core/src/schema/nodes/box-vent.ts index 124ade6f73..141e9235fc 100644 --- a/packages/core/src/schema/nodes/box-vent.ts +++ b/packages/core/src/schema/nodes/box-vent.ts @@ -23,6 +23,8 @@ export const BoxVentNode = BaseNode.extend({ position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.number().default(0), + // Missing on old scenes: preserve their original unflashed footprint. + flashingWidth: z.number().min(0).optional(), width: z.number().default(0.4), depth: z.number().default(0.4), height: z.number().default(0.15), diff --git a/packages/core/src/schema/nodes/hvac-equipment.ts b/packages/core/src/schema/nodes/hvac-equipment.ts index 6bde1e2c15..359d507e8a 100644 --- a/packages/core/src/schema/nodes/hvac-equipment.ts +++ b/packages/core/src/schema/nodes/hvac-equipment.ts @@ -31,6 +31,11 @@ export const HvacEquipmentNode = BaseNode.extend({ width: z.number().min(0.3).max(2).default(0.56), depth: z.number().min(0.3).max(2).default(0.71), height: z.number().min(0.4).max(2.5).default(1.1), + supplyFace: z.enum(['top', 'left', 'right']).default('top'), + returnFace: z.enum(['left', 'right']).default('left'), + serviceFace: z.enum(['left', 'right']).default('right'), + suctionDiameter: z.number().min(0.25).max(2).default(0.875), + liquidDiameter: z.number().min(0.125).max(1).default(0.375), // Duct collar cross-section on the supply / return connections. Round is // the default; rect and oval (flat-oval) match the duct shapes a run // might mate with. Condensers carry no duct collars (ignored). @@ -53,6 +58,8 @@ export const HvacEquipmentNode = BaseNode.extend({ - rotation: yaw radians - equipmentType: furnace | air-handler | condenser - width / depth / height: cabinet size in meters + - supplyFace: top | left | right; returnFace / serviceFace: left | right + - suctionDiameter / liquidDiameter: separate refrigerant connection sizes in inches - supplyShape / returnShape: round | rect | oval duct collar cross-section (ignored by condenser) - supplyDiameter / returnDiameter: round collar sizes in inches - supplyWidth / supplyHeight / returnWidth / returnHeight: rect / oval collar cross-section in inches diff --git a/packages/core/src/schema/nodes/lineset.ts b/packages/core/src/schema/nodes/lineset.ts index 06bc7ff3a2..0917a74bf2 100644 --- a/packages/core/src/schema/nodes/lineset.ts +++ b/packages/core/src/schema/nodes/lineset.ts @@ -2,21 +2,9 @@ import dedent from 'dedent' import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' -/** - * Refrigerant lineset — the copper pipe pair that links the outdoor - * condenser to the indoor coil (furnace / air handler) of a split system. - * It is the refrigerant-side analogue of a duct run: a polyline of points, - * but carrying two lines instead of one airway. - * - * Real linesets run a fat insulated SUCTION line (cool vapour back to the - * compressor) beside a thin bare LIQUID line (warm liquid out to the coil). - * The geometry builder draws a single copper line on the path centerline - * (sized to `suctionDiameter`, wrapped in a foam jacket when `insulated`); - * draw the liquid line as a second lineset rather than both off one path. - * - * Path coordinates are level-local meters: [x, y, z] tuples, same space as - * duct paths and grid events. Diameters are nominal copper OD in inches. - */ +/** Single refrigerant suction route, retaining the persisted `lineset` kind. + * The liquid route uses `liquid-line`. Path points are level-local metres; + * diameters are nominal copper OD in inches. */ export const LinesetNode = BaseNode.extend({ id: objectId('lineset'), type: nodeType('lineset'), @@ -25,17 +13,16 @@ export const LinesetNode = BaseNode.extend({ // Nominal suction-line copper OD in inches (the large insulated line). // Common residential sizes are 3/4"–1-1/8". suctionDiameter: z.number().min(0.25).max(2).default(0.875), - // Nominal liquid-line copper OD in inches (the small bare line). - // Common residential sizes are 1/4"–3/8". + // Retained when loading/saving old scenes; only liquid-line renders the liquid route. liquidDiameter: z.number().min(0.125).max(1).default(0.375), // Whether the suction line carries its foam insulation jacket. Bare = false. insulated: z.boolean().default(true), }).describe( dedent` - Refrigerant lineset - copper suction + liquid pair linking a condenser to an indoor coil. + Refrigerant suction line linking a condenser to an indoor coil. The separate liquid-line node models the liquid route. - path: list of [x, y, z] points in level-local meters (min 2) - suctionDiameter: nominal copper OD in inches of the large insulated line (typ. 3/4"-1-1/8") - - liquidDiameter: nominal copper OD in inches of the small bare line (typ. 1/4"-3/8") + - liquidDiameter: retained legacy data; does not change this suction-line geometry - insulated: whether the suction line wears its foam jacket `, ) diff --git a/packages/core/src/services/distribution-frame.ts b/packages/core/src/services/distribution-frame.ts new file mode 100644 index 0000000000..18ab3ae201 --- /dev/null +++ b/packages/core/src/services/distribution-frame.ts @@ -0,0 +1,85 @@ +import type { AnyNode, AnyNodeId, BuildingNode } from '../schema' +import { getLevelElevations } from './storey' + +export function distributionPointToWorld( + node: AnyNode, + point: readonly [number, number, number], + nodes: Readonly>, + elevations = getLevelElevations(nodes), +): [number, number, number] { + let ancestor: AnyNode | undefined = node + const visited = new Set() + while (ancestor && ancestor.type !== 'level' && !visited.has(ancestor.id)) { + visited.add(ancestor.id) + ancestor = ancestor.parentId ? nodes[ancestor.parentId as AnyNodeId] : undefined + } + const elevation = ancestor?.type === 'level' ? elevations.get(ancestor.id) : undefined + const building = elevation?.buildingId ? nodes[elevation.buildingId as AnyNodeId] : undefined + let [x, y, z] = point + y += elevation?.baseY ?? 0 + if (building?.type === 'building') { + const { position, rotation } = building as BuildingNode + const [rx, ry, rz] = rotation + const zx = Math.cos(rz) * x - Math.sin(rz) * y + const zy = Math.sin(rz) * x + Math.cos(rz) * y + const yx = Math.cos(ry) * zx + Math.sin(ry) * z + const yz = -Math.sin(ry) * zx + Math.cos(ry) * z + x = yx + position[0] + y = Math.cos(rx) * zy - Math.sin(rx) * yz + position[1] + z = Math.sin(rx) * zy + Math.cos(rx) * yz + position[2] + } + return [x, y, z] +} + +export type DistributionFrame = { + origin: [number, number, number] + axes: [number, number, number][] +} + +export function distributionFrame( + node: AnyNode, + nodes: Readonly>, + elevations = getLevelElevations(nodes), +): DistributionFrame { + const origin = distributionPointToWorld(node, [0, 0, 0], nodes, elevations) + const axes = ( + [ + [1, 0, 0], + [0, 1, 0], + [0, 0, 1], + ] as const + ).map((axis) => { + const point = distributionPointToWorld(node, axis, nodes, elevations) + return point.map((value, i) => value - origin[i]!) as [number, number, number] + }) + return { origin, axes } +} + +export function distributionVectorToWorld( + frame: DistributionFrame, + point: readonly number[], +): [number, number, number] { + return [0, 1, 2].map((i) => + frame.axes.reduce((sum, axis, j) => sum + axis[i]! * point[j]!, 0), + ) as [number, number, number] +} + +export function distributionPointFromWorld( + frame: DistributionFrame, + point: readonly number[], +): [number, number, number] { + return frame.axes.map((axis) => + axis.reduce((sum, value, i) => sum + value * (point[i]! - frame.origin[i]!), 0), + ) as [number, number, number] +} + +export function distributionPointInFrame( + frame: DistributionFrame, + point: readonly number[], +): [number, number, number] { + return distributionVectorToWorld(frame, point).map((value, i) => value + frame.origin[i]!) as [ + number, + number, + number, + ] +} diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index fdd6d793f5..ef3dacb2c6 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -78,6 +78,13 @@ export { type VerticalFeature, type WallExtent, } from './opening-guides' +export { + type ConnectionCompatibility, + type ConnectionProfile, + connectionCompatibility, + portConnection, + snapPlanarPortPosition, +} from './port-connection' export { analyzePortConnectivity, type PortConnection, diff --git a/packages/core/src/services/port-connection.test.ts b/packages/core/src/services/port-connection.test.ts new file mode 100644 index 0000000000..57663a66fe --- /dev/null +++ b/packages/core/src/services/port-connection.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from 'bun:test' +import { connectionCompatibility, type NodePort, portConnection } from '../index' + +const outlet: NodePort = { + id: 'out', + position: [0, 0, 0], + direction: [1, 0, 0], + diameter: 8, + system: 'supply', +} +const inlet: NodePort = { ...outlet, id: 'in', direction: [-1, 0, 0] } + +test('physical joints require proximity, opposing directions, system and section agreement', () => { + expect(portConnection(outlet, inlet).status).toBe('match') + expect(portConnection(outlet, { ...inlet, position: [0.06, 0, 0] }).status).toBe('incompatible') + expect(portConnection(outlet, { ...inlet, direction: [0, 1, 0] }).status).toBe('incompatible') + expect(portConnection(outlet, { ...inlet, direction: [0, 0, 0] }).status).toBe('unknown') + expect(portConnection(outlet, { ...inlet, system: 'return' }).status).toBe('incompatible') + expect(portConnection(outlet, { ...inlet, diameter: 10 }).status).toBe('adapter') + expect(portConnection(outlet, { ...inlet, shape: 'rect', width: 8, height: 8 }).status).toBe( + 'adapter', + ) + expect(portConnection(outlet, { ...inlet, system: undefined }).status).toBe('unknown') +}) + +test('refrigerant subtypes cannot join, but node-local flow groups need not share an id', () => { + const suction = { ...outlet, system: 'refrigerant', connectionType: 'suction' } + const liquid = { ...inlet, system: 'refrigerant', connectionType: 'liquid' } + expect(portConnection(suction, liquid).status).toBe('incompatible') + expect( + portConnection({ ...outlet, group: 'equipment-air' }, { ...inlet, group: 'run' }).status, + ).toBe('match') +}) + +test('rectangular profile comparison uses dimensions instead of equivalent diameter', () => { + const section = { ...outlet, shape: 'rect' as const, width: 12, height: 8 } + expect(connectionCompatibility(section, { ...section, diameter: 99 }).status).toBe('match') + expect(connectionCompatibility(section, { ...section, width: 8, height: 12 }).status).toBe( + 'adapter', + ) + expect(connectionCompatibility(section, { ...section, width: undefined }).status).toBe('unknown') +}) diff --git a/packages/core/src/services/port-connection.ts b/packages/core/src/services/port-connection.ts new file mode 100644 index 0000000000..3c3144a5db --- /dev/null +++ b/packages/core/src/services/port-connection.ts @@ -0,0 +1,142 @@ +import { findLevelAncestorId } from '../lib/node-ancestry' +import { type NodePort, nodeRegistry } from '../registry' +import type { AnyNode } from '../schema' + +export type ConnectionProfile = Pick< + NodePort, + 'system' | 'diameter' | 'shape' | 'width' | 'height' | 'connectionType' +> +export type ConnectionCompatibility = { + status: 'match' | 'adapter' | 'incompatible' | 'unknown' + label: string +} + +export function connectionCompatibility( + source: ConnectionProfile, + target: ConnectionProfile, +): ConnectionCompatibility { + if (source.system && target.system && source.system !== target.system) { + return { + status: 'incompatible', + label: `Different systems: ${source.system} / ${target.system}`, + } + } + if ( + source.connectionType && + target.connectionType && + source.connectionType !== target.connectionType + ) { + return { + status: 'incompatible', + label: `Different connections: ${source.connectionType} / ${target.connectionType}`, + } + } + const sourceShape = source.shape ?? 'round' + const targetShape = target.shape ?? 'round' + if (sourceShape !== targetShape) { + return { status: 'adapter', label: `Transition needed: ${sourceShape} / ${targetShape}` } + } + const sourceSize = sourceShape === 'round' ? [source.diameter] : [source.width, source.height] + const targetSize = targetShape === 'round' ? [target.diameter] : [target.width, target.height] + if ( + [...sourceSize, ...targetSize].some( + (value) => value === undefined || !Number.isFinite(value) || value <= 0, + ) + ) { + return { status: 'unknown', label: 'Connection size unavailable' } + } + if (sourceSize.some((value, index) => Math.abs(value! - targetSize[index]!) > 0.001)) { + return { + status: 'adapter', + label: `Reducer needed: ${sourceSize.join(' × ')}″ / ${targetSize.join(' × ')}″`, + } + } + if (!source.system || !target.system) { + return { status: 'unknown', label: 'Size matches; system unspecified' } + } + return { status: 'match', label: `Matching system and size: ${target.system}` } +} + +/** Evaluate a proposed physical joint; positions and directions must share a frame. */ +export function portConnection(source: NodePort, target: NodePort): ConnectionCompatibility { + if ( + ![...source.position, ...target.position, ...source.direction, ...target.direction].every( + Number.isFinite, + ) + ) { + return { status: 'unknown', label: 'Connection coordinates unavailable' } + } + if ( + Math.hypot(...source.position.map((value, index) => value - target.position[index]!)) > 0.05 + ) { + return { status: 'incompatible', label: 'Ports are not joined' } + } + const compatibility = connectionCompatibility(source, target) + if (compatibility.status !== 'match') return compatibility + const a = Math.hypot(...source.direction) + const b = Math.hypot(...target.direction) + if (a < 1e-9 || b < 1e-9) return { status: 'unknown', label: 'Connection direction unavailable' } + const dot = + source.direction.reduce((sum, value, index) => sum + value * target.direction[index]!, 0) / + (a * b) + if (dot > -0.95) return { status: 'incompatible', label: 'Ports must face each other' } + return compatibility +} + +/** Snap a planar move only where its fixed-height ports can form a valid joint. */ +export function snapPlanarPortPosition( + node: AnyNode, + candidate: [number, number], + nodes: Readonly>, + config: { systems?: readonly string[]; radius?: number }, +): [number, number] | null { + const nodePos = (node as { position?: [number, number, number] }).position + if (!nodePos) return null + const ownPorts = nodeRegistry.get(node.type)?.ports?.(node) + if (!ownPorts || ownPorts.length === 0) return null + + const radius = config.radius ?? 0.5 + const radiusSq = radius * radius + const { systems } = config + const dragDx = candidate[0] - nodePos[0] + const dragDz = candidate[1] - nodePos[2] + + const levelId = findLevelAncestorId(node.id, nodes) + let bestDistSq = radiusSq + let snap: [number, number] | null = null + + for (const node2 of Object.values(nodes)) { + if ( + !node2 || + node2.id === node.id || + node2.visible === false || + findLevelAncestorId(node2.id, nodes) !== levelId + ) + continue + const targets = nodeRegistry.get(node2.type)?.ports?.(node2) + if (!targets) continue + for (const target of targets) { + if (systems && target.system !== undefined && !systems.includes(target.system)) continue + for (const own of ownPorts) { + // Own port at the candidate position = stored port + drag delta. + const ownX = own.position[0] + dragDx + const ownZ = own.position[2] + dragDz + const dx = target.position[0] - ownX + const dz = target.position[2] - ownZ + const distSq = dx * dx + dz * dz + if ( + distSq <= bestDistSq && + portConnection( + { ...own, position: [target.position[0], own.position[1], target.position[2]] }, + target, + ).status === 'match' + ) { + bestDistSq = distSq + // Shift the node so this own port lands on the target (XZ only). + snap = [candidate[0] + dx, candidate[1] + dz] + } + } + } + } + return snap +} diff --git a/packages/core/src/services/port-connectivity.test.ts b/packages/core/src/services/port-connectivity.test.ts index 6b1da0963f..0d0c2d2ae9 100644 --- a/packages/core/src/services/port-connectivity.test.ts +++ b/packages/core/src/services/port-connectivity.test.ts @@ -34,8 +34,20 @@ stubDef('duct-segment', 'run', (node) => { const path = (node as unknown as { path: Point[] }).path const system = (node as unknown as { system: string }).system return [ - { id: 'start', position: path[0]!, direction: [-1, 0, 0], diameter: 6, system }, - { id: 'end', position: path[path.length - 1]!, direction: [1, 0, 0], diameter: 6, system }, + { + id: 'start', + position: path[0]!, + direction: path[0]!.map((v, i) => v - path[1]![i]!) as Point, + diameter: 6, + system, + }, + { + id: 'end', + position: path[path.length - 1]!, + direction: path.at(-1)!.map((v, i) => v - path.at(-2)![i]!) as Point, + diameter: 6, + system, + }, ] }) stubDef('duct-fitting', 'fitting', (node) => { @@ -206,6 +218,10 @@ describe('port connectivity — joint follow (stretch vs translate)', () => { const moved = makeNode('duct-segment', { path: [ [0, 0, 0], + [-1, 0, 0], + [-1, 0, -1], + [4, 0, -1], + [4, 0, 0], [3, 0, 0], ], system: 'supply', @@ -238,6 +254,10 @@ describe('port connectivity — joint follow (stretch vs translate)', () => { const moved = makeNode('duct-segment', { path: [ [0, 0, 0], + [-1, 0, 0], + [-1, 0, -1], + [4, 0, -1], + [4, 0, 3], [3, 0, 3], ], system: 'supply', @@ -276,6 +296,10 @@ describe('port connectivity — joint follow (stretch vs translate)', () => { const moved = makeNode('duct-segment', { path: [ [-0.2, 0, 0], + [-1, 0, 0], + [-1, 0, -1], + [4, 0, -1], + [4, 0, 0], [0.2, 0, 0], ], system: 'supply', @@ -284,7 +308,7 @@ describe('port connectivity — joint follow (stretch vs translate)', () => { const downstream = makeNode('duct-segment', { path: [ [0, 0, 0.2], - [3, 0, 0.2], + [0, 0, 3], ], system: 'supply', }) @@ -306,13 +330,17 @@ describe('port connectivity — joint follow (stretch vs translate)', () => { ) const path = (updates.find((u) => u.id === downstream.id)!.data as { path: Point[] }).path expectPointClose(path[0]!, [0, 0, 1.200025]) - expectPointClose(path[1]!, [3, 0, 1.200025]) + expectPointClose(path[1]!, [0, 0, 3]) }) test('a fitting reached from incompatible collars merges constraints deterministically', () => { const moved = makeNode('duct-segment', { path: [ [-0.2, 0, 0], + [-1, 0, 0], + [-1, 0, -1], + [4, 0, -1], + [4, 0, 0], [0.2, 0, 0], ], system: 'supply', @@ -408,3 +436,120 @@ test('kind-owned connectivity keeps fixed neighbors and snapshots custom move fo [5, 0, 0], ]) }) + +test('connected movement does not join matching local endpoints on different floors', () => { + const lower = makeNode('level', { level: 0, height: 3, children: [] }) + const upper = makeNode('level', { level: 1, height: 3, children: [] }) + const a = makeNode('duct-segment', { + parentId: lower.id, + system: 'supply', + path: [ + [0, 0, 0], + [3, 0, 0], + ], + }) + const b = makeNode('duct-segment', { + parentId: upper.id, + system: 'supply', + path: [ + [3, 0, 0], + [6, 0, 0], + ], + }) + expect(analyzePortConnectivity(a, sceneOf(lower, upper, a, b)).connections).toEqual([]) +}) + +test('a real cross-floor joint follows in world space and returns level-local patches', () => { + const lower = makeNode('level', { level: 0, height: 3, children: [] }) + const upper = makeNode('level', { level: 1, height: 3, children: [] }) + const a = makeNode('duct-segment', { + parentId: lower.id, + system: 'supply', + path: [ + [0, 0, 0], + [0, 3, 0], + ], + }) + const b = makeNode('duct-segment', { + parentId: upper.id, + system: 'supply', + path: [ + [0, 0, 0], + [0, 2, 0], + ], + }) + const snapshot = analyzePortConnectivity(a, sceneOf(lower, upper, a, b)) + expect(snapshot.connections).toEqual([ + { + kind: 'run', + nodeId: b.id, + startPath: [ + [0, 0, 0], + [0, 2, 0], + ], + }, + ]) + const preview = { + ...a, + path: [ + [0, 0, 0], + [1, 3, 0], + ], + } as AnyNode + expect(resolveConnectivityUpdates(snapshot, preview)).toEqual([ + { + id: b.id, + data: { + path: [ + [1, 0, 0], + [1, 2, 0], + ], + }, + }, + ]) +}) + +test('movement across differently rotated building frames preserves follower coordinates', () => { + const building = makeNode('building', { + position: [10, 0, 0], + rotation: [0, Math.PI / 2, 0], + children: [], + }) + const level = makeNode('level', { parentId: building.id, level: 0, height: 3, children: [] }) + const a = makeNode('duct-segment', { + system: 'supply', + path: [ + [10, 0, 2], + [10, 0, 0], + ], + }) + const b = makeNode('duct-segment', { + parentId: level.id, + system: 'supply', + path: [ + [0, 0, 0], + [2, 0, 0], + ], + }) + const snapshot = analyzePortConnectivity(a, sceneOf(building, level, a, b)) + expect(snapshot.connections).toEqual([ + { + kind: 'run', + nodeId: b.id, + startPath: [ + [0, 0, 0], + [2, 0, 0], + ], + }, + ]) + const updates = resolveConnectivityUpdates(snapshot, { + ...a, + path: [ + [10, 0, 2], + [11, 0, 0], + ], + } as AnyNode) + const path = (updates[0]!.data as { path: Point[] }).path + expectPointClose(path[0]!, [0, 0, 1]) + expectPointClose(path[1]!, [2, 0, 1]) +}) diff --git a/packages/core/src/services/port-connectivity.ts b/packages/core/src/services/port-connectivity.ts index f44160e9c8..4dc3aa2d7c 100644 --- a/packages/core/src/services/port-connectivity.ts +++ b/packages/core/src/services/port-connectivity.ts @@ -1,5 +1,14 @@ -import { nodeRegistry } from '../registry' +import { type NodePort, nodeRegistry } from '../registry' import type { AnyNode, AnyNodeId } from '../schema' +import { + type DistributionFrame, + distributionFrame, + distributionPointFromWorld, + distributionPointInFrame, + distributionVectorToWorld, +} from './distribution-frame' +import { portConnection } from './port-connection' +import { getLevelElevations } from './storey' /** * Connectivity-aware editing for port-bearing distribution kinds @@ -40,11 +49,6 @@ import type { AnyNode, AnyNodeId } from '../schema' type Point = readonly [number, number, number] -/** Distance (meters) under which two ports count as the same joint. Joints - * formed by placement snapping coincide to sub-millimeter; 5 cm leaves - * generous slack for grid-snapped hand placement without false matches. */ -const COINCIDENT_EPS_M = 0.05 - /** Below this (meters) a propagated delta is treated as zero — stops the * walk from chasing sub-millimeter perpendicular residue. */ const DELTA_EPS_M = 1e-4 @@ -75,7 +79,7 @@ export type PortConnection = type GraphNode = { id: AnyNodeId role: 'run' | 'fitting' - ports: ReadonlyArray<{ id: string; position: Point; system?: string }> + ports: readonly NodePort[] startPath?: Point[] startPosition?: Point } @@ -84,6 +88,7 @@ type GraphNode = { type Adjacency = Record>> export type PortConnectivity = { + frames?: Record customResolver?: (preview: AnyNode) => { id: AnyNodeId; data: Partial }[] movedNodeId: AnyNodeId /** The moved node's port world positions at edit-start, keyed by port id — @@ -98,12 +103,12 @@ export type PortConnectivity = { connections: PortConnection[] } -function portsOf( - node: AnyNode, -): ReadonlyArray<{ id: string; position: Point; system?: string }> | undefined { - return nodeRegistry.get(node.type)?.ports?.(node) as - | ReadonlyArray<{ id: string; position: Point; system?: string }> - | undefined +function portsOf(node: AnyNode, frame: DistributionFrame): NodePort[] { + return (nodeRegistry.get(node.type)?.ports?.(node) ?? []).map((port) => ({ + ...port, + position: distributionPointInFrame(frame, port.position), + direction: distributionVectorToWorld(frame, port.direction), + })) } /** A node's distribution role from the registry (run / fitting / …). */ @@ -111,25 +116,6 @@ function roleOf(node: AnyNode): string | undefined { return nodeRegistry.get(node.type)?.distributionRole } -function distSq(a: Point, b: Point): number { - const dx = a[0] - b[0] - const dy = a[1] - b[1] - const dz = a[2] - b[2] - return dx * dx + dy * dy + dz * dz -} - -/** Two ports mate when they coincide AND don't cross incompatible systems - * (a supply duct and a waste pipe that merely touch must not fuse). */ -function portsMate( - a: { position: Point; system?: string }, - b: { position: Point; system?: string }, - epsSq: number, -): boolean { - if (distSq(a.position, b.position) > epsSq) return false - if (a.system && b.system && a.system !== b.system) return false - return true -} - /** * Snapshot the joint graph reachable from `movedNode`'s ports, taken at the * start of a move/resize. Call once before the drag; feed the result to @@ -178,9 +164,11 @@ export function analyzePortConnectivity( }), } } - const epsSq = COINCIDENT_EPS_M * COINCIDENT_EPS_M - - const movedPorts = portsOf(movedNode) ?? [] + const elevations = getLevelElevations(nodes) + const frames: Record = {} + const frameOf = (node: AnyNode) => + (frames[node.id] ??= distributionFrame(node, nodes, elevations)) + const movedPorts = portsOf(movedNode, frameOf(movedNode)) const startMovedPorts: Record = {} for (const p of movedPorts) startMovedPorts[p.id] = p.position @@ -193,18 +181,20 @@ export function analyzePortConnectivity( if (role === 'fitting' && nodeRegistry.get(other.type)?.portConnectivityFollow === false) { continue } - const ports = portsOf(other) - if (!ports) continue + const ports = portsOf(other, frameOf(other)) + if (ports.length === 0) continue const startPath = role === 'run' - ? (other as unknown as { path?: Point[] }).path?.map((p) => [...p] as Point) + ? (other as unknown as { path?: Point[] }).path?.map((p) => + distributionPointInFrame(frameOf(other), p), + ) : undefined if (role === 'run' && (!startPath || startPath.length < 2)) continue const startPosition = role === 'fitting' ? (() => { const pos = (other as unknown as { position?: Point }).position - return pos ? ([pos[0], pos[1], pos[2]] as Point) : undefined + return pos ? distributionPointInFrame(frameOf(other), pos) : undefined })() : undefined if (role === 'fitting' && !startPosition) continue @@ -229,7 +219,7 @@ export function analyzePortConnectivity( // Seed: the moved node's own ports. const queue: Array<{ id: string - ports: ReadonlyArray<{ id: string; position: Point; system?: string }> + ports: readonly NodePort[] }> = [{ id: movedNode.id, ports: movedPorts }] while (queue.length > 0) { @@ -238,7 +228,7 @@ export function analyzePortConnectivity( for (const cand of candidates) { if (cand.id === id) continue for (const cp of cand.ports) { - if (!portsMate(port, cp, epsSq)) continue + if (portConnection(port, cp).status !== 'match') continue addEdge(id, port.id, { nodeId: cand.id, portId: cp.id }) addEdge(cand.id, cp.id, { nodeId: id as AnyNodeId, portId: port.id }) if (!visited.has(cand.id)) { @@ -253,13 +243,24 @@ export function analyzePortConnectivity( const connections: PortConnection[] = Object.values(graph).map((g) => g.role === 'fitting' - ? { kind: 'rigid-node', nodeId: g.id, startPosition: g.startPosition! } - : { kind: 'run', nodeId: g.id, startPath: g.startPath! }, + ? { + kind: 'rigid-node', + nodeId: g.id, + startPosition: [...(nodes[g.id] as unknown as { position: Point }).position] as Point, + } + : { + kind: 'run', + nodeId: g.id, + startPath: (nodes[g.id] as unknown as { path: Point[] }).path.map( + (point) => [...point] as Point, + ), + }, ) return { movedNodeId: movedNode.id as AnyNodeId, startMovedPorts, + frames, graph, adjacency, connections, @@ -391,7 +392,7 @@ export function resolveConnectivityUpdates( const { graph, adjacency, startMovedPorts, movedNodeId } = connectivity if (Object.keys(graph).length === 0) return [] - const newPorts = portsOf(previewNode) ?? [] + const newPorts = portsOf(previewNode, connectivity.frames![movedNodeId]!) const newMovedPos: Record = {} for (const p of newPorts) newMovedPos[p.id] = p.position @@ -476,5 +477,16 @@ export function resolveConnectivityUpdates( } } - return Object.values(results) + return Object.values(results).map((result) => { + const frame = connectivity.frames![result.id]! + const data = result.data as { position?: Point; path?: Point[] } + return { + id: result.id, + data: data.path + ? ({ + path: data.path.map((point) => distributionPointFromWorld(frame, point)), + } as Partial) + : ({ position: distributionPointFromWorld(frame, data.position!) } as Partial), + } + }) } diff --git a/packages/core/src/services/system-graph.test.ts b/packages/core/src/services/system-graph.test.ts index decd1c7f5f..f330e4c3d3 100644 --- a/packages/core/src/services/system-graph.test.ts +++ b/packages/core/src/services/system-graph.test.ts @@ -34,11 +34,17 @@ const runPorts = (node: AnyNode): NodePort[] => { const path = (node as unknown as { path: Point[] }).path const system = (node as unknown as { system: string }).system return [ - { id: 'start', position: path[0]!, direction: [-1, 0, 0], diameter: 6, system }, + { + id: 'start', + position: path[0]!, + direction: path[0]!.map((v, i) => v - path[1]![i]!) as Point, + diameter: 6, + system, + }, { id: 'end', position: path[path.length - 1]!, - direction: [1, 0, 0], + direction: path.at(-1)!.map((v, i) => v - path.at(-2)![i]!) as Point, diameter: 6, system, }, @@ -48,11 +54,11 @@ stubDef('duct-segment', 'run', runPorts) stubDef('pipe-segment', 'run', runPorts) stubDef('hvac-equipment', 'equipment', (node) => { const position = (node as unknown as { position: Point }).position - return [{ id: 'supply', position, direction: [0, 1, 0], diameter: 12, system: 'supply' }] + return [{ id: 'supply', position, direction: [1, 0, 0], diameter: 6, system: 'supply' }] }) stubDef('duct-terminal', 'terminal', (node) => { const position = (node as unknown as { position: Point }).position - return [{ id: 'collar', position, direction: [0, -1, 0], diameter: 6, system: 'supply' }] + return [{ id: 'collar', position, direction: [-1, 0, 0], diameter: 6, system: 'supply' }] }) let nextId = 0 @@ -77,7 +83,7 @@ describe('buildPortComponents', () => { ]) const b = run([ [3, 0, 0], - [3, 0, 4], + [7, 0, 0], ]) // shares a's end const c = run([ [20, 0, 0], @@ -210,10 +216,10 @@ describe('summarizeSystemFor', () => { ]) const branch = run([ [4, 0, 0], - [4, 0, 3], + [7, 0, 0], ]) const register = makeNode('duct-terminal', { - position: [4, 0, 3] as Point, + position: [7, 0, 0] as Point, terminalType: 'supply-register', }) const scene = sceneOf(furnace, trunk, branch, register) diff --git a/packages/core/src/services/system-graph.ts b/packages/core/src/services/system-graph.ts index 5abeb0cd5c..6beec1625c 100644 --- a/packages/core/src/services/system-graph.ts +++ b/packages/core/src/services/system-graph.ts @@ -1,7 +1,16 @@ import { type NodePort, nodeRegistry } from '../registry' -import type { AnyNode, AnyNodeId, BuildingNode } from '../schema' +import type { AnyNode, AnyNodeId } from '../schema' +import { + distributionFrame, + distributionPointInFrame, + distributionVectorToWorld, +} from './distribution-frame' import { getLevelElevations } from './storey' +export { distributionPointToWorld } from './distribution-frame' + +import { portConnection } from './port-connection' + /** * The "System" primitive: connected components over the port graph. * @@ -16,10 +25,6 @@ import { getLevelElevations } from './storey' * share it. */ -/** Distance (meters) under which two ports count as the same joint — - * matches port-connectivity's tolerance for hand-placed joints. */ -const COINCIDENT_EPS_M = 0.05 - export type SystemSummary = { /** Every node in this connected component. */ nodeIds: AnyNodeId[] @@ -47,12 +52,19 @@ export type SystemPort = { export function collectSystemPorts(nodes: Readonly>): SystemPort[] { const result: SystemPort[] = [] + const elevations = getLevelElevations(nodes) for (const node of Object.values(nodes)) { if (!node) continue const ports = nodeRegistry.get(node.type)?.ports?.(node) if (!ports) continue - for (const port of ports) { - const [x, y, z] = distributionPointToWorld(node, port.position, nodes) + const frame = distributionFrame(node, nodes, elevations) + for (const localPort of ports) { + const port = { + ...localPort, + position: distributionPointInFrame(frame, localPort.position), + direction: distributionVectorToWorld(frame, localPort.direction), + } + const [x, y, z] = port.position result.push({ port, nodeId: node.id, @@ -66,36 +78,6 @@ export function collectSystemPorts(nodes: Readonly>): return result } -export function distributionPointToWorld( - node: AnyNode, - point: readonly [number, number, number], - nodes: Readonly>, -): [number, number, number] { - const elevations = getLevelElevations(nodes) - let ancestor: AnyNode | undefined = node - const visited = new Set() - while (ancestor && ancestor.type !== 'level' && !visited.has(ancestor.id)) { - visited.add(ancestor.id) - ancestor = ancestor.parentId ? nodes[ancestor.parentId as AnyNodeId] : undefined - } - const elevation = ancestor?.type === 'level' ? elevations.get(ancestor.id) : undefined - const building = elevation?.buildingId ? nodes[elevation.buildingId as AnyNodeId] : undefined - let [x, y, z] = point - y += elevation?.baseY ?? 0 - if (building?.type === 'building') { - const { position, rotation } = building as BuildingNode - const [rx, ry, rz] = rotation - const zx = Math.cos(rz) * x - Math.sin(rz) * y - const zy = Math.sin(rz) * x + Math.cos(rz) * y - const yx = Math.cos(ry) * zx + Math.sin(ry) * z - const yz = -Math.sin(ry) * zx + Math.cos(ry) * z - x = yx + position[0] - y = Math.cos(rx) * zy - Math.sin(rx) * yz + position[1] - z = Math.sin(rx) * zy + Math.cos(rx) * yz + position[2] - } - return [x, y, z] -} - /** Union-find over node ids. */ class Components { private parent = new Map() @@ -134,18 +116,13 @@ function pathLength(path: ReadonlyArray): num export function buildPortComponents(nodes: Readonly>): AnyNodeId[][] { const ports = collectSystemPorts(nodes) const components = new Components() - const epsSq = COINCIDENT_EPS_M * COINCIDENT_EPS_M for (let i = 0; i < ports.length; i++) { const a = ports[i]! for (let j = i + 1; j < ports.length; j++) { const b = ports[j]! if (a.nodeId === b.nodeId) continue - if (a.system && b.system && a.system !== b.system) continue - const dx = a.x - b.x - const dy = a.y - b.y - const dz = a.z - b.z - if (dx * dx + dy * dy + dz * dz <= epsSq) components.union(a.nodeId, b.nodeId) + if (portConnection(a.port, b.port).status === 'match') components.union(a.nodeId, b.nodeId) } } diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx index ef1de406d7..81844aaf8a 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx @@ -4,6 +4,7 @@ import { type AlignmentAnchor, type AnyNode, type AnyNodeId, + analyzePortConnectivity, bboxAnchors, bboxCornerAnchors, beginSceneHistoryDraft, @@ -17,10 +18,12 @@ import { getEffectiveNode, type MovableConfig, nodeRegistry, + resolveConnectivityUpdates, runAsSingleSceneHistoryStep, runSceneHistoryDraftWrite, sceneHistoryDraftRevertUpdates, settleSceneHistoryDrafts, + snapPlanarPortPosition, useLiveNodeOverrides, useLiveTransforms, useScene, @@ -660,6 +663,24 @@ export function FloorplanRegistryMoveOverlay() { (movingNode as { metadata?: unknown }).metadata, ) + const connectivity = isFreshPlacement ? null : analyzePortConnectivity(movingNode, sceneNodes) + let connectedUpdates: { id: AnyNodeId; data: Partial }[] = [] + const clearConnectedPreview = () => { + for (const update of connectedUpdates) + useLiveNodeOverrides.getState().clearFields(update.id, Object.keys(update.data)) + connectedUpdates = [] + } + const updateConnectedPreview = (preview: AnyNode) => { + if (!connectivity) return + const next = resolveConnectivityUpdates(connectivity, preview) + for (const update of connectedUpdates) { + if (!next.some((candidate) => candidate.id === update.id)) + useLiveNodeOverrides.getState().clearFields(update.id, Object.keys(update.data)) + } + connectedUpdates = next + useLiveNodeOverrides.getState().setMany(next.map((update) => [update.id, update.data])) + } + // SVG units in this floorplan map 1:1 to world meters, and the // `` entry has no transform of its own when at rest, // so its untransformed bbox IS the world-space footprint. Cache the @@ -833,6 +854,29 @@ export function FloorplanRegistryMoveOverlay() { useAlignmentGuides.getState().clear() } + const portSnap = def?.capabilities?.movable?.portSnap + if (isMagneticSnapActive() && portSnap) { + const snapped = snapPlanarPortPosition( + { ...movingNode, rotation: currentRotation } as AnyNode, + [finalX, finalZ], + sceneNodes, + portSnap, + ) + if (snapped) [finalX, finalZ] = snapped + } + updateConnectedPreview({ + ...movingNode, + ...(originalPath + ? { + path: originalPath.map(([x, y, z]) => [ + x + finalX - originalPosition[0], + y, + z + finalZ - originalPosition[2], + ]), + } + : { position: [finalX, originalPosition[1], finalZ], rotation: currentRotation }), + } as AnyNode) + const transform = rigidPlanSvgTransform({ from: [originalPosition[0], originalPosition[2]], fromRotation: originalRotation, @@ -906,18 +950,21 @@ export function FloorplanRegistryMoveOverlay() { const nextPath = originalPath.map( ([x, y, z]) => [x + dx, y, z + dz] as [number, number, number], ) - useScene.getState().updateNode( - movingNode.id as AnyNodeId, - (isFreshPlacement - ? { - path: nextPath, - metadata: stripPlacementMetadataFlags( - (movingNode as { metadata?: unknown }).metadata, - ), - visible: true, - } - : { path: nextPath }) as Partial, - ) + runAsSingleSceneHistoryStep(useScene, () => { + useScene.getState().updateNodes(connectedUpdates) + useScene.getState().updateNode( + movingNode.id as AnyNodeId, + (isFreshPlacement + ? { + path: nextPath, + metadata: stripPlacementMetadataFlags( + (movingNode as { metadata?: unknown }).metadata, + ), + visible: true, + } + : { path: nextPath }) as Partial, + ) + }) useViewer.getState().setSelection({ selectedIds: [movingNode.id as AnyNodeId] }) for (const relatedEntry of relatedEntries) { relatedEntry.removeAttribute('transform') @@ -944,13 +991,16 @@ export function FloorplanRegistryMoveOverlay() { if (atomicPreview) setMovingNodeOrigin('2d') selectedId = committedId ?? selectedId } else { - useScene.getState().updateNode( - movingNode.id as AnyNodeId, - { - position: [sx, oldY, sz], - ...rotationPatch, - } as Partial, - ) + runAsSingleSceneHistoryStep(useScene, () => { + useScene.getState().updateNodes(connectedUpdates) + useScene.getState().updateNode( + movingNode.id as AnyNodeId, + { + position: [sx, oldY, sz], + ...rotationPatch, + } as Partial, + ) + }) } useViewer.getState().setSelection({ selectedIds: [selectedId] }) for (const relatedEntry of relatedEntries) { @@ -988,6 +1038,7 @@ export function FloorplanRegistryMoveOverlay() { window.addEventListener('pointerup', onPointerUp) window.addEventListener('keydown', onKey, true) return () => { + clearConnectedPreview() window.removeEventListener('pointermove', onMove) window.removeEventListener('pointerup', onPointerUp) window.removeEventListener('keydown', onKey, true) diff --git a/packages/editor/src/components/tools/distribution/connection-compatibility.ts b/packages/editor/src/components/tools/distribution/connection-compatibility.ts index 73eb51e460..7f176f728e 100644 --- a/packages/editor/src/components/tools/distribution/connection-compatibility.ts +++ b/packages/editor/src/components/tools/distribution/connection-compatibility.ts @@ -1,43 +1,5 @@ -import type { NodePort } from '@pascal-app/core' - -export type ConnectionProfile = Pick -export type ConnectionCompatibility = { - status: 'match' | 'adapter' | 'incompatible' | 'unknown' - label: string -} - -export function connectionCompatibility( - source: ConnectionProfile, - target: ConnectionProfile, -): ConnectionCompatibility { - if (source.system && target.system && source.system !== target.system) { - return { - status: 'incompatible', - label: `Different systems: ${source.system} / ${target.system}`, - } - } - const sourceShape = source.shape ?? 'round' - const targetShape = target.shape ?? 'round' - if (sourceShape !== targetShape) { - return { status: 'adapter', label: `Transition needed: ${sourceShape} / ${targetShape}` } - } - const sourceSize = sourceShape === 'round' ? [source.diameter] : [source.width, source.height] - const targetSize = targetShape === 'round' ? [target.diameter] : [target.width, target.height] - if ( - [...sourceSize, ...targetSize].some( - (value) => value === undefined || !Number.isFinite(value) || value <= 0, - ) - ) { - return { status: 'unknown', label: 'Connection size unavailable' } - } - if (sourceSize.some((value, index) => Math.abs(value! - targetSize[index]!) > 0.001)) { - return { - status: 'adapter', - label: `Reducer needed: ${sourceSize.join(' × ')}″ / ${targetSize.join(' × ')}″`, - } - } - if (!source.system || !target.system) { - return { status: 'unknown', label: 'Size matches; system unspecified' } - } - return { status: 'match', label: `Matching system and size: ${target.system}` } -} +export { + type ConnectionCompatibility, + type ConnectionProfile, + connectionCompatibility, +} from '@pascal-app/core' diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx index e6fd5c8460..7fa1d392f1 100644 --- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx +++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx @@ -33,6 +33,7 @@ import { resolveSupportSlabPatch, type SurfaceRejectReason, sceneRegistry, + snapPlanarPortPosition, spatialGridManager, useLiveNodeOverrides, useLiveTransforms, @@ -103,8 +104,6 @@ export function resolveMoveRotationStep( return freeRotation + delta } -/** Default magnetic radius (meters, XZ) for `movable.portSnap`. */ -const PORT_SNAP_RADIUS_M = 0.5 const VALID_COLOR = 0x22_c5_5e const INVALID_COLOR = 0xef_44_44 @@ -147,62 +146,6 @@ function alignmentGuideFromParentFrameMatch(match: ParentFrameSnapMatch): Alignm } } -/** - * Magnetic port snap for a dragged node: if one of the node's own ports - * (read live from `def.ports`) lands within `radius` of a matching scene - * port at the candidate XZ, return the node XZ that mates them exactly. - * - * Pure core: ports come through `nodeRegistry` so this stays layer-clean. - * Ports are level-local meters — the same frame as the cursor's - * `localPosition`, so no extra transform is needed. The dragged node's - * ports move rigidly with its position, so a port at candidate `(x,z)` - * sits at `portStored + (candidate - nodeStored)`. We pick the closest - * (own-port, target-port) pair and shift the node so they coincide in XZ. - */ -function resolvePortSnap( - node: AnyNode, - candidate: [number, number], - config: { systems?: readonly string[]; radius?: number }, -): [number, number] | null { - const nodePos = (node as { position?: [number, number, number] }).position - if (!nodePos) return null - const ownPorts = nodeRegistry.get(node.type)?.ports?.(node) - if (!ownPorts || ownPorts.length === 0) return null - - const radius = config.radius ?? PORT_SNAP_RADIUS_M - const radiusSq = radius * radius - const { systems } = config - const dragDx = candidate[0] - nodePos[0] - const dragDz = candidate[1] - nodePos[2] - - const nodes = useScene.getState().nodes - let bestDistSq = radiusSq - let snap: [number, number] | null = null - - for (const node2 of Object.values(nodes)) { - if (!node2 || node2.id === node.id) continue - const targets = nodeRegistry.get(node2.type)?.ports?.(node2) - if (!targets) continue - for (const target of targets) { - if (systems && target.system !== undefined && !systems.includes(target.system)) continue - for (const own of ownPorts) { - // Own port at the candidate position = stored port + drag delta. - const ownX = own.position[0] + dragDx - const ownZ = own.position[2] + dragDz - const dx = target.position[0] - ownX - const dz = target.position[2] - ownZ - const distSq = dx * dx + dz * dz - if (distSq <= bestDistSq) { - bestDistSq = distSq - // Shift the node so this own port lands on the target (XZ only). - snap = [candidate[0] + dx, candidate[1] + dz] - } - } - } - } - return snap -} - /** Figma-style alignment-snap threshold (meters), matching the 2D * floor-plan overlay's `ALIGNMENT_THRESHOLD_M`. 8 cm gives a magnetic pull * without fighting grid snap. Fixed for v1 — no zoom-scaling in 3D. */ @@ -963,7 +906,12 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) { // this the snap solver mates the pre-rotation collar and commit then // writes the rotated node offset from the port it visually snapped to. const snapNode = buildPreviewNode(originalPosition, rotationRef.current) - const mated = resolvePortSnap(snapNode, [x, z], portSnapConfig) + const mated = snapPlanarPortPosition( + snapNode, + [x, z], + useScene.getState().nodes, + portSnapConfig, + ) if (mated) { x = mated[0] z = mated[1] diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 5292aca737..67c6547209 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -870,6 +870,7 @@ export { getWallDrawVariant, selectWallDrawVariant, useWallDrawVariant, + WALL_DRAW_VARIANTS, type WallDrawVariant, } from './lib/wall-draw-variant' export { diff --git a/packages/nodes/src/box-vent/__tests__/geometry.test.ts b/packages/nodes/src/box-vent/__tests__/geometry.test.ts index 90e1a4af74..644e9016a6 100644 --- a/packages/nodes/src/box-vent/__tests__/geometry.test.ts +++ b/packages/nodes/src/box-vent/__tests__/geometry.test.ts @@ -156,3 +156,40 @@ describe('computeBoxVentSlopeTilt', () => { expect(computeBoxVentSlopeTilt(undefined, 1)).toBe(0) }) }) + +test.each(['box', 'cap', 'dome'] as const)( + 'flashing extends the %s footprint and retains paintable surfaces', + (style) => { + const node = BoxVentNode.parse({ style, flashingWidth: 0.06 }) + const geometry = buildBoxVentGeometry(node) + geometry.computeBoundingBox() + const extra = 0.06 + (style === 'box' ? 0 : node.hoodOverhang) + expect(geometry.boundingBox!.max.x).toBeCloseTo(node.width / 2 + extra) + expect(geometry.boundingBox!.max.z).toBeCloseTo(node.depth / 2 + extra) + expect(geometry.boundingBox!.min.y).toBeCloseTo(0) + expect(geometry.boundingBox!.max.y).toBeCloseTo(node.height + 0.003) + expect(new Set(geometry.groups.map((group) => group.materialIndex))).toEqual(new Set([0, 1])) + expect(geometry.getAttribute('uv2').count).toBe(geometry.getAttribute('position').count) + geometry.dispose() + }, +) + +test('a cap opening has a recessed throat between the body and hood', () => { + const node = BoxVentNode.parse({ style: 'cap', capGap: 0.03 }) + const geometry = buildBoxVentGeometry(node) + const positions = geometry.getAttribute('position') + const bodyTop = node.height - node.capHeight - node.capGap + const hoodBottom = node.height - node.capHeight + let reachesBody = false + let reachesHood = false + for (let i = 0; i < positions.count; i++) { + const x = Math.abs(positions.getX(i)) + const z = Math.abs(positions.getZ(i)) + if (x < node.width / 2 - 0.01 && z < node.depth / 2 - 0.01 && x > 0.01) { + reachesBody ||= Math.abs(positions.getY(i) - bodyTop) < 1e-6 + reachesHood ||= Math.abs(positions.getY(i) - hoodBottom) < 1e-6 + } + } + expect(reachesBody && reachesHood).toBe(true) + geometry.dispose() +}) diff --git a/packages/nodes/src/box-vent/__tests__/schema.test.ts b/packages/nodes/src/box-vent/__tests__/schema.test.ts index 31d356e487..d4a4de1b96 100644 --- a/packages/nodes/src/box-vent/__tests__/schema.test.ts +++ b/packages/nodes/src/box-vent/__tests__/schema.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { BoxVentNode } from '../schema' +import { BoxVentNode, boxVentDefinition } from '../index' describe('BoxVentNode schema', () => { test('parses with sensible defaults', () => { @@ -55,3 +55,35 @@ describe('BoxVentNode schema', () => { expect(a.id).not.toBe(b.id) }) }) + +test('advertised box vent styles are current schema values, while old scenes still migrate', () => { + const style = boxVentDefinition + .parametrics!.groups!.flatMap((group) => group.fields) + .find((field) => field.key === 'style')! + expect(style.kind).toBe('enum') + if (style.kind !== 'enum') throw new Error('Missing style choices') + expect(style.options).toEqual(['box', 'cap', 'dome']) + for (const value of style.options) expect(BoxVentNode.parse({ style: value }).style).toBe(value) + expect(BoxVentNode.parse({ style: 'standard' }).style).toBe('cap') + expect(BoxVentNode.parse({ style: 'low-profile' }).style).toBe('box') +}) + +test('old scenes retain their footprint while newly placed vents get flashing and an opening', () => { + const old = BoxVentNode.parse({ width: 0.4, height: 0.15 }) + expect(old.flashingWidth).toBeUndefined() + expect(old.capGap).toBe(0) + const fresh = BoxVentNode.parse(boxVentDefinition.defaults()) + expect(fresh.flashingWidth).toBe(0.05) + expect(fresh.capGap).toBe(0.02) + expect(BoxVentNode.safeParse({ flashingWidth: -1 }).success).toBe(false) +}) + +test('reducing the housing dimensions keeps editable details inside it', () => { + const old = BoxVentNode.parse({ style: 'cap', capHeight: 0.1, capGap: 0.04 }) + const patch = { height: 0.05, width: 0.1, depth: 0.1 } + const next = { ...old, ...patch } + const derived = boxVentDefinition.parametrics!.derive!(next, patch, old) + expect(derived.capHeight! + derived.capGap!).toBeLessThanOrEqual(patch.height - 0.01 + 1e-8) + expect(derived.baseInset! * 2).toBeLessThan(patch.width) + expect(derived.baseHeight!).toBeLessThan(patch.height) +}) diff --git a/packages/nodes/src/box-vent/definition.ts b/packages/nodes/src/box-vent/definition.ts index 53fd52d3e4..14f66a2f6a 100644 --- a/packages/nodes/src/box-vent/definition.ts +++ b/packages/nodes/src/box-vent/definition.ts @@ -179,10 +179,16 @@ export const boxVentDefinition: NodeDefinition = { schemaVersion: 3, schema: BoxVentNode, category: 'structure', + snapProfile: 'item', surfaceRole: 'roof', defaults: () => { - const stub = BoxVentNodeSchema.parse({ id: 'bvent_default' as never, type: 'box-vent' }) + const stub = BoxVentNodeSchema.parse({ + id: 'bvent_default' as never, + type: 'box-vent', + flashingWidth: 0.05, + capGap: 0.02, + }) const { id: _id, type: _type, ...rest } = stub return rest }, @@ -217,6 +223,7 @@ export const boxVentDefinition: NodeDefinition = { }, toolHints: [ { key: 'Left click', label: 'Place box vent on roof' }, + { key: 'Alt', label: 'Place freely beyond roof edge' }, { key: 'Esc', label: 'Cancel' }, ], @@ -230,6 +237,6 @@ export const boxVentDefinition: NodeDefinition = { mcp: { description: - 'A louvered box vent sitting on a roof segment. Style: standard / low-profile / dome. Width/depth/height/hoodOverhang parametric.', + 'A louvered box vent sitting on a roof segment. Style: box / cap / dome. Housing dimensions, flashingWidth, cap gap and style-specific shape controls are parametric.', }, } diff --git a/packages/nodes/src/box-vent/dimensions.ts b/packages/nodes/src/box-vent/dimensions.ts new file mode 100644 index 0000000000..38cc870fe6 --- /dev/null +++ b/packages/nodes/src/box-vent/dimensions.ts @@ -0,0 +1,34 @@ +import type { BoxVentNode } from '@pascal-app/core' + +/** Keep editable subparts inside the housing's physical envelope. */ +export function boxVentDimensions(node: BoxVentNode) { + const width = Math.max(0.01, node.width) + const depth = Math.max(0.01, node.depth) + const height = Math.max(0.015, node.height) + const baseInset = Math.max( + 0, + Math.min(node.baseInset ?? 0.06, Math.min(width, depth) / 2 - 0.005), + ) + const capHeight = Math.max(0.005, Math.min(node.capHeight ?? 0.07, height - 0.01)) + return { + width, + depth, + height, + baseInset, + capHeight, + baseHeight: Math.max(0.005, Math.min(node.baseHeight ?? 0.04, height - 0.005)), + cornerBevel: Math.max( + 0, + Math.min(node.cornerBevel ?? 0.012, (Math.min(width, depth) - 2 * baseInset) / 2 - 0.001), + ), + capGap: Math.max(0, Math.min(node.capGap ?? 0, height - 0.01 - capHeight)), + } +} + +export function boxVentDimensionPatch( + node: BoxVentNode, + patch: Partial, +): Partial { + const dimensions = boxVentDimensions(node) + return Object.keys(patch).some((key) => key in dimensions) ? dimensions : {} +} diff --git a/packages/nodes/src/box-vent/floorplan.ts b/packages/nodes/src/box-vent/floorplan.ts index 9bc555788d..7c3456c0bc 100644 --- a/packages/nodes/src/box-vent/floorplan.ts +++ b/packages/nodes/src/box-vent/floorplan.ts @@ -89,6 +89,19 @@ export function buildBoxVentFloorplan( } const children: FloorplanGeometry[] = [] + if ((node.flashingWidth ?? 0) > 0) { + const extra = + (node.flashingWidth ?? 0) + (style === 'box' ? 0 : Math.max(0, node.hoodOverhang ?? 0.04)) + children.push({ + kind: 'polygon', + points: rect(hw + extra, hd + extra), + fill, + fillOpacity: fillOpacity * 0.5, + stroke, + strokeWidth: lineWidth, + pointerEvents: 'all', + }) + } if (style === 'dome') { // Outer = the round flange plate (dome radius + flange overhang). diff --git a/packages/nodes/src/box-vent/geometry.ts b/packages/nodes/src/box-vent/geometry.ts index 408bbcb398..73b12ef545 100644 --- a/packages/nodes/src/box-vent/geometry.ts +++ b/packages/nodes/src/box-vent/geometry.ts @@ -1,38 +1,50 @@ import { type BoxVentNode, getActiveRoofHeight, type RoofType } from '@pascal-app/core' import * as THREE from 'three' +import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { copyUvToSecondaryChannel } from '../shared/primitive-uv' +import { boxVentDimensions } from './dimensions' export const BOX_VENT_MATERIAL_INDEX = { base: 0, top: 1, } as const -/** - * 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) - return buildDomeStyleShape(node) +/** Shared housing and flashing geometry for the renderer, preview and exports. */ +export function buildBoxVentGeometry(input: BoxVentNode): THREE.BufferGeometry { + const node = { ...input, ...boxVentDimensions(input) } + const housing = + node.style === 'box' + ? buildBoxShape(node) + : node.style === 'cap' + ? buildCapShape(node) + : buildDomeStyleShape(node) + const flashing = Math.max(0, node.flashingWidth ?? 0) + if (flashing === 0) return housing + const overhang = node.style === 'box' ? 0 : Math.max(0, node.hoodOverhang ?? 0.04) + const positions: number[] = [] + const normals: number[] = [] + const uvs: number[] = [] + buildRoundedExtrusion( + positions, + normals, + uvs, + node.width + 2 * (overhang + flashing), + node.depth + 2 * (overhang + flashing), + 0, + 0.003, + 0.008, + ) + const plate = buildBufferGeometry(positions, normals, uvs, positions.length / 3) + housing.translate(0, 0.003, 0) + const geometry = mergeGeometries([plate, housing], false)! + geometry.clearGroups() + const plateCount = plate.getAttribute('position').count + for (const group of housing.groups) + geometry.addGroup(plateCount + group.start, group.count, group.materialIndex) + geometry.addGroup(0, plateCount, BOX_VENT_MATERIAL_INDEX.base) + plate.dispose() + housing.dispose() + return geometry } // ─── Box style ─────────────────────────────────────────────────────── @@ -282,7 +294,7 @@ function buildCapShape(node: BoxVentNode): THREE.BufferGeometry { [0, -1, 0], ) - // ── Body top (only when there's a visible gap to look through) + // A recessed throat connects the hood across the ventilation slot. if (capGap > 0) { pushQuad( positions, @@ -296,6 +308,18 @@ function buildCapShape(node: BoxVentNode): THREE.BufferGeometry { ) } + if (capGap > 0) { + buildRoundedExtrusion( + positions, + normals, + uvs, + w * 0.78, + d * 0.78, + bodyH, + y0, + Math.min(w, d) * 0.02, + ) + } const topStartVertex = positions.length / 3 // ── Flange underside (the bit of the cap base that overhangs the body) diff --git a/packages/nodes/src/box-vent/index.ts b/packages/nodes/src/box-vent/index.ts index 16497fa292..01dca01e9a 100644 --- a/packages/nodes/src/box-vent/index.ts +++ b/packages/nodes/src/box-vent/index.ts @@ -1,3 +1,6 @@ export { boxVentDefinition } from './definition' +export { boxVentDimensions } from './dimensions' export { buildBoxVentGeometry, computeBoxVentSlopeTilt } from './geometry' + +export { resolveBoxVentPlacement } from './placement' export { BoxVentNode } from './schema' diff --git a/packages/nodes/src/box-vent/move-tool.tsx b/packages/nodes/src/box-vent/move-tool.tsx index 1c9ec30208..9056822c2c 100644 --- a/packages/nodes/src/box-vent/move-tool.tsx +++ b/packages/nodes/src/box-vent/move-tool.tsx @@ -11,25 +11,30 @@ import { useScene, } from '@pascal-app/core' import { + commitFreshPlacementSubtree, consumePlacementDragRelease, + isGridSnapActive, + isMagneticSnapActive, markToolCancelConsumed, + stripPlacementMetadataFlags, triggerSFX, useEditor, } from '@pascal-app/editor' +import { getRoofOuterSurfaceFrameAtPoint } from '@pascal-app/viewer' import { useCallback, useEffect, useState } from 'react' import * as THREE from 'three' import { createRelativeRoofDrag, type RelativeRoofDragTarget, roofSegmentLocalToBuildingLocal, - snapRelativeRoofDragTarget, } from '../shared/relative-roof-drag' -import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' +import { surfaceQuatFromNormal } from '../shared/roof-surface' import { clearRoofSurfacePlacementGuides, publishRoofSurfaceNodePlacementGuides, snapRoofSurfaceNodeTarget, } from '../shared/roof-surface-placement-guides' +import { resolveBoxVentPlacement } from './placement' import BoxVentPreview from './preview' /** @@ -48,6 +53,7 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) { const [previewPos, setPreviewPos] = useState<[number, number, number] | null>(null) const [previewSurfaceQuat, setPreviewSurfaceQuat] = useState(null) + const [previewValid, setPreviewValid] = useState(true) const [previewYaw, setPreviewYaw] = useState(0) useEffect(() => { @@ -72,6 +78,15 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) { let lastSnap: [number, number] | null = null let lastTarget: RelativeRoofDragTarget | null = null let committed = false + let forcePlacement = false + const originalSegment = original.roofSegmentId + ? (useScene.getState().nodes[original.roofSegmentId as AnyNodeId] as RoofSegmentNode) + : undefined + const roofOffset = originalSegment + ? original.position[1] - + getRoofOuterSurfaceFrameAtPoint(originalSegment, original.position[0], original.position[2]) + .point.y + : 0 const roofDrag = createRelativeRoofDrag(original) const clearTarget = () => { @@ -85,11 +100,32 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) { const resolveSnappedTarget = (event: RoofEvent): RelativeRoofDragTarget | null => { const rawTarget = roofDrag.resolve(event) if (!rawTarget) return null - return snapRoofSurfaceNodeTarget({ - target: snapRelativeRoofDragTarget(rawTarget, event.nativeEvent?.shiftKey === true), + forcePlacement = event.nativeEvent?.altKey === true + let placement = resolveBoxVentPlacement( + rawTarget.segment, node, - bypass: event.nativeEvent?.shiftKey === true, - }) + rawTarget.localX, + rawTarget.localZ, + { + roofOffset, + gridStep: !forcePlacement && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0, + }, + ) + let target = { + ...rawTarget, + localX: placement.position[0], + localY: placement.position[1], + localZ: placement.position[2], + } + if (!forcePlacement && isMagneticSnapActive()) { + target = snapRoofSurfaceNodeTarget({ target, node }) + placement = resolveBoxVentPlacement(target.segment, node, target.localX, target.localZ, { + roofOffset, + }) + target = { ...target, localY: placement.position[1] } + } + setPreviewValid(placement.valid || forcePlacement) + return target } const updatePreview = (event: RoofEvent) => { @@ -103,14 +139,19 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) { const sx = Math.round(target.localX * 20) / 20 const sz = Math.round(target.localZ * 20) / 20 if ( - event.nativeEvent?.shiftKey !== true && + !forcePlacement && + (isGridSnapActive() || isMagneticSnapActive()) && (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) ) { triggerSFX('sfx:grid-snap') lastSnap = [sx, sz] } - const normal = getAnalyticalNormal(target.localX, target.localZ, target.segment) + const normal = getRoofOuterSurfaceFrameAtPoint( + target.segment, + target.localX, + target.localZ, + ).normal setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion())) setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0)) setPreviewPos( @@ -131,43 +172,33 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) { const onRoofClick = (event: RoofEvent) => { if (committed) return - const target = lastTarget ?? resolveSnappedTarget(event) + const target = event.node ? resolveSnappedTarget(event) : lastTarget if (!target) return + if ( + !forcePlacement && + !resolveBoxVentPlacement(target.segment, node, target.localX, target.localZ, { roofOffset }) + .valid + ) + return committed = true const targetSegmentId = target.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, { + const patch = { roofSegmentId: targetSegmentId, parentId: targetSegmentId, - position: [target.localX, target.localY, target.localZ], + position: [target.localX, target.localY, target.localZ] as [number, number, number], rotation: original.rotation, visible: true, - metadata: {}, - }) - useScene.temporal.getState().pause() + metadata: stripPlacementMetadataFlags(original.metadata) as BoxVentNode['metadata'], + } + if (isNew) { + commitFreshPlacementSubtree(node.id as AnyNodeId, patch) + } else { + useScene.temporal.getState().resume() + st.updateNode(node.id as AnyNodeId, patch) + useScene.temporal.getState().pause() + } st.dirtyNodes.add(targetSegmentId) st.dirtyNodes.add(node.id as AnyNodeId) @@ -261,7 +292,7 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) { - + diff --git a/packages/nodes/src/box-vent/panel.tsx b/packages/nodes/src/box-vent/panel.tsx index 2979041553..37dc95616d 100644 --- a/packages/nodes/src/box-vent/panel.tsx +++ b/packages/nodes/src/box-vent/panel.tsx @@ -3,7 +3,6 @@ import { type AnyNode, type AnyNodeId, - getActiveRoofHeight, type RoofSegmentNode, useLiveNodeOverrides, useScene, @@ -14,14 +13,15 @@ import { duplicateNodeAndPickUp, PanelSection, PanelWrapper, - SegmentedControl, SliderControl, triggerSFX, useEditor, } from '@pascal-app/editor' -import { useViewer } from '@pascal-app/viewer' +import { getRoofOuterSurfaceFrameAtPoint, useViewer } from '@pascal-app/viewer' import { Copy, Move, Trash2 } from 'lucide-react' import { useCallback } from 'react' +import { getDownSlopeYaw } from '../shared/roof-surface' +import { boxVentDimensionPatch, boxVentDimensions } from './dimensions' import type { BoxVentNode } from './schema' /** @@ -52,9 +52,11 @@ export default function BoxVentPanel() { const overrides = useLiveNodeOverrides((s) => selectedId ? (s.get(selectedId as AnyNodeId) as Partial | undefined) : undefined, ) - const node: BoxVentNode | undefined = + const effectiveNode: BoxVentNode | undefined = storeNode && overrides ? ({ ...storeNode, ...overrides } as BoxVentNode) : storeNode + const node = effectiveNode ? { ...effectiveNode, ...boxVentDimensions(effectiveNode) } : undefined + // 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). @@ -70,9 +72,12 @@ export default function BoxVentPanel() { const previewProp = useCallback( (updates: Partial) => { if (!selectedId) return - useLiveNodeOverrides.getState().set(selectedId as AnyNodeId, updates) + useLiveNodeOverrides.getState().set(selectedId as AnyNodeId, { + ...updates, + ...(node ? boxVentDimensionPatch({ ...node, ...updates }, updates) : {}), + }) }, - [selectedId], + [selectedId, node], ) // Slider release (commit): flush to the scene store as a single @@ -80,10 +85,13 @@ export default function BoxVentPanel() { const commitProp = useCallback( (updates: Partial) => { if (!selectedId) return - updateNode(selectedId as AnyNode['id'], updates) + updateNode(selectedId as AnyNode['id'], { + ...updates, + ...(node ? boxVentDimensionPatch({ ...node, ...updates }, updates) : {}), + }) useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId) }, - [selectedId, updateNode], + [selectedId, updateNode, node], ) // Discrete / one-shot updates (style switch, end-cap toggle) bypass @@ -137,6 +145,10 @@ export default function BoxVentPanel() { if (!(node && node.type === 'box-vent' && selectedId)) return null + const roofY = (x: number, z: number) => + segment ? getRoofOuterSurfaceFrameAtPoint(segment, x, z).point.y : 0 + const roofOffset = node.position[1] - roofY(node.position[0], node.position[2]) + return ( - handleUpdate({ style: v as BoxVentNode['style'] })} - options={[ - { label: 'Box', value: 'box' }, - { label: 'Cap', value: 'cap' }, - { label: 'Dome', value: 'dome' }, - ]} - value={node.style ?? 'cap'} - /> +