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 0000000000..e45b944114 Binary files /dev/null and b/apps/editor/public/icons/box-vent/box.webp differ 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 0000000000..4bda11cc30 Binary files /dev/null and b/apps/editor/public/icons/box-vent/cap.webp differ 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 0000000000..624692d20e Binary files /dev/null and b/apps/editor/public/icons/box-vent/dome.webp differ diff --git a/apps/editor/public/icons/canopy/butterfly.webp b/apps/editor/public/icons/canopy/butterfly.webp new file mode 100644 index 0000000000..761ff877ad Binary files /dev/null and b/apps/editor/public/icons/canopy/butterfly.webp differ diff --git a/apps/editor/public/icons/canopy/gable.webp b/apps/editor/public/icons/canopy/gable.webp new file mode 100644 index 0000000000..9375a20658 Binary files /dev/null and b/apps/editor/public/icons/canopy/gable.webp differ diff --git a/apps/editor/public/icons/canopy/mono.webp b/apps/editor/public/icons/canopy/mono.webp new file mode 100644 index 0000000000..db0cc44bf0 Binary files /dev/null and b/apps/editor/public/icons/canopy/mono.webp differ diff --git a/apps/editor/public/icons/chimney/brick.webp b/apps/editor/public/icons/chimney/brick.webp new file mode 100644 index 0000000000..913cd9d09f Binary files /dev/null and b/apps/editor/public/icons/chimney/brick.webp differ diff --git a/apps/editor/public/icons/chimney/modern.webp b/apps/editor/public/icons/chimney/modern.webp new file mode 100644 index 0000000000..aeaa03ad54 Binary files /dev/null and b/apps/editor/public/icons/chimney/modern.webp differ diff --git a/apps/editor/public/icons/chimney/round.webp b/apps/editor/public/icons/chimney/round.webp new file mode 100644 index 0000000000..6a9d8e713f Binary files /dev/null and b/apps/editor/public/icons/chimney/round.webp differ diff --git a/apps/editor/public/icons/cupola/dome.webp b/apps/editor/public/icons/cupola/dome.webp new file mode 100644 index 0000000000..28e4108226 Binary files /dev/null and b/apps/editor/public/icons/cupola/dome.webp differ diff --git a/apps/editor/public/icons/cupola/pyramid.webp b/apps/editor/public/icons/cupola/pyramid.webp new file mode 100644 index 0000000000..683b1b59c0 Binary files /dev/null and b/apps/editor/public/icons/cupola/pyramid.webp differ diff --git a/apps/editor/public/icons/dormer/dutch.webp b/apps/editor/public/icons/dormer/dutch.webp new file mode 100644 index 0000000000..692b5b7460 Binary files /dev/null and b/apps/editor/public/icons/dormer/dutch.webp differ diff --git a/apps/editor/public/icons/dormer/flat.webp b/apps/editor/public/icons/dormer/flat.webp new file mode 100644 index 0000000000..6aebdbbe09 Binary files /dev/null and b/apps/editor/public/icons/dormer/flat.webp differ diff --git a/apps/editor/public/icons/dormer/gable.webp b/apps/editor/public/icons/dormer/gable.webp new file mode 100644 index 0000000000..492196b688 Binary files /dev/null and b/apps/editor/public/icons/dormer/gable.webp differ diff --git a/apps/editor/public/icons/dormer/gambrel.webp b/apps/editor/public/icons/dormer/gambrel.webp new file mode 100644 index 0000000000..2eb7adc096 Binary files /dev/null and b/apps/editor/public/icons/dormer/gambrel.webp differ diff --git a/apps/editor/public/icons/dormer/hip.webp b/apps/editor/public/icons/dormer/hip.webp new file mode 100644 index 0000000000..4f49ef8320 Binary files /dev/null and b/apps/editor/public/icons/dormer/hip.webp differ diff --git a/apps/editor/public/icons/dormer/mansard.webp b/apps/editor/public/icons/dormer/mansard.webp new file mode 100644 index 0000000000..88cd0c323d Binary files /dev/null and b/apps/editor/public/icons/dormer/mansard.webp differ diff --git a/apps/editor/public/icons/dormer/shed.webp b/apps/editor/public/icons/dormer/shed.webp new file mode 100644 index 0000000000..248ef70e89 Binary files /dev/null and b/apps/editor/public/icons/dormer/shed.webp differ diff --git a/apps/editor/public/icons/downspout/kickout.webp b/apps/editor/public/icons/downspout/kickout.webp new file mode 100644 index 0000000000..5ae9677736 Binary files /dev/null and b/apps/editor/public/icons/downspout/kickout.webp differ diff --git a/apps/editor/public/icons/downspout/rect.webp b/apps/editor/public/icons/downspout/rect.webp new file mode 100644 index 0000000000..75baf392a3 Binary files /dev/null and b/apps/editor/public/icons/downspout/rect.webp differ diff --git a/apps/editor/public/icons/downspout/round.webp b/apps/editor/public/icons/downspout/round.webp new file mode 100644 index 0000000000..196b81e6b8 Binary files /dev/null and b/apps/editor/public/icons/downspout/round.webp differ diff --git a/apps/editor/public/icons/downspout/splash.webp b/apps/editor/public/icons/downspout/splash.webp new file mode 100644 index 0000000000..9f2a6f7efa Binary files /dev/null and b/apps/editor/public/icons/downspout/splash.webp differ diff --git a/apps/editor/public/icons/downspout/straight.webp b/apps/editor/public/icons/downspout/straight.webp new file mode 100644 index 0000000000..e418533dca Binary files /dev/null and b/apps/editor/public/icons/downspout/straight.webp differ 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 0000000000..b66905f9a6 Binary files /dev/null and b/apps/editor/public/icons/eyebrow-vent/half-round.webp differ 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 0000000000..9883a92155 Binary files /dev/null and b/apps/editor/public/icons/eyebrow-vent/scoop.webp differ 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 0000000000..3d313d22ee Binary files /dev/null and b/apps/editor/public/icons/eyebrow-vent/slant-box.webp differ diff --git a/apps/editor/public/icons/gutter/box.webp b/apps/editor/public/icons/gutter/box.webp new file mode 100644 index 0000000000..cab5f7fb82 Binary files /dev/null and b/apps/editor/public/icons/gutter/box.webp differ 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 0000000000..9b9a8ddff2 Binary files /dev/null and b/apps/editor/public/icons/gutter/half-round.webp differ 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 0000000000..8945d195d6 Binary files /dev/null and b/apps/editor/public/icons/gutter/k-style.webp differ 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 0000000000..4960384eaa Binary files /dev/null and b/apps/editor/public/icons/ridge-vent/metal.webp differ 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 0000000000..5419597dd6 Binary files /dev/null and b/apps/editor/public/icons/ridge-vent/shingled.webp differ diff --git a/apps/editor/public/icons/ridge-vent/standard.webp b/apps/editor/public/icons/ridge-vent/standard.webp new file mode 100644 index 0000000000..6672a950d0 Binary files /dev/null and b/apps/editor/public/icons/ridge-vent/standard.webp differ diff --git a/apps/editor/public/icons/skylight/flat.webp b/apps/editor/public/icons/skylight/flat.webp new file mode 100644 index 0000000000..7bee9a9a96 Binary files /dev/null and b/apps/editor/public/icons/skylight/flat.webp differ diff --git a/apps/editor/public/icons/skylight/lantern.webp b/apps/editor/public/icons/skylight/lantern.webp new file mode 100644 index 0000000000..c98684dc64 Binary files /dev/null and b/apps/editor/public/icons/skylight/lantern.webp differ diff --git a/apps/editor/public/icons/skylight/opening.webp b/apps/editor/public/icons/skylight/opening.webp new file mode 100644 index 0000000000..ca8f84b3a3 Binary files /dev/null and b/apps/editor/public/icons/skylight/opening.webp differ diff --git a/apps/editor/public/icons/skylight/sliding.webp b/apps/editor/public/icons/skylight/sliding.webp new file mode 100644 index 0000000000..059797f7cd Binary files /dev/null and b/apps/editor/public/icons/skylight/sliding.webp differ diff --git a/apps/editor/public/icons/skylight/walk-on.webp b/apps/editor/public/icons/skylight/walk-on.webp new file mode 100644 index 0000000000..439e1746b1 Binary files /dev/null and b/apps/editor/public/icons/skylight/walk-on.webp differ diff --git a/apps/editor/public/icons/solar-panel/compact.webp b/apps/editor/public/icons/solar-panel/compact.webp new file mode 100644 index 0000000000..76061c88b0 Binary files /dev/null and b/apps/editor/public/icons/solar-panel/compact.webp differ 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 0000000000..72706277b5 Binary files /dev/null and b/apps/editor/public/icons/solar-panel/frameless.webp differ diff --git a/apps/editor/public/icons/solar-panel/residential-large.webp b/apps/editor/public/icons/solar-panel/residential-large.webp new file mode 100644 index 0000000000..c511c7d2ae Binary files /dev/null and b/apps/editor/public/icons/solar-panel/residential-large.webp differ 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 0000000000..b84a043dab Binary files /dev/null and b/apps/editor/public/icons/solar-panel/residential.webp differ diff --git a/apps/editor/public/icons/turbine-vent/cylinder.webp b/apps/editor/public/icons/turbine-vent/cylinder.webp new file mode 100644 index 0000000000..794ad352d0 Binary files /dev/null and b/apps/editor/public/icons/turbine-vent/cylinder.webp differ 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 0000000000..6f4b9932e0 Binary files /dev/null and b/apps/editor/public/icons/turbine-vent/globe.webp differ diff --git a/packages/core/src/contracts/reference-inventory.ts b/packages/core/src/contracts/reference-inventory.ts index 40fee5bd10..a7bc7fd029 100644 --- a/packages/core/src/contracts/reference-inventory.ts +++ b/packages/core/src/contracts/reference-inventory.ts @@ -697,6 +697,13 @@ export const EXISTING_REFERENCES: readonly ExistingReference[] = [ /** Candidate paths that are not references, with the reason. `kind: '*'` matches every kind. */ export const NON_REFERENCES: readonly { kind: string; path: string; reason: string }[] = [ + { kind: 'hvac-equipment', path: 'supplyFace', reason: 'Cabinet side enum, not a node reference' }, + { kind: 'hvac-equipment', path: 'returnFace', reason: 'Cabinet side enum, not a node reference' }, + { + kind: 'hvac-equipment', + path: 'serviceFace', + reason: 'Cabinet side enum, not a node reference', + }, { kind: '*', path: 'id', reason: "The node's own id: a definition, not a reference." }, { kind: '*', @@ -951,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/core/src/registry/plugin-v1-conformance.test.ts b/packages/core/src/registry/plugin-v1-conformance.test.ts index e1b67fc7c2..8cd7bbcf19 100644 --- a/packages/core/src/registry/plugin-v1-conformance.test.ts +++ b/packages/core/src/registry/plugin-v1-conformance.test.ts @@ -118,7 +118,18 @@ const pipeDef: NodeDefinition = { 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..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. @@ -31,6 +32,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 +59,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..1eabc8f711 100644 --- a/packages/core/src/schema/nodes/lineset.ts +++ b/packages/core/src/schema/nodes/lineset.ts @@ -2,40 +2,28 @@ 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'), + 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). // 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/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/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..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, @@ -870,6 +874,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..4c58c04e6b 100644 --- a/packages/nodes/src/box-vent/geometry.ts +++ b/packages/nodes/src/box-vent/geometry.ts @@ -1,38 +1,51 @@ 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' 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. */ +function buildUnsmoothedBoxVentGeometry(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 +295,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 +309,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) @@ -682,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/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..903c2daa71 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 { useCallback, useRef } from 'react' +import { getDownSlopeYaw } from '../shared/roof-surface' +import { boxVentDimensionPatch, boxVentDimensions } from './dimensions' import type { BoxVentNode } from './schema' /** @@ -52,9 +52,14 @@ 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 + + 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). @@ -70,7 +75,11 @@ export default function BoxVentPanel() { const previewProp = useCallback( (updates: Partial) => { if (!selectedId) return - useLiveNodeOverrides.getState().set(selectedId as AnyNodeId, updates) + const node = nodeRef.current + useLiveNodeOverrides.getState().set(selectedId as AnyNodeId, { + ...updates, + ...(node ? boxVentDimensionPatch({ ...node, ...updates }, updates) : {}), + }) }, [selectedId], ) @@ -80,7 +89,11 @@ export default function BoxVentPanel() { const commitProp = useCallback( (updates: Partial) => { if (!selectedId) return - updateNode(selectedId as AnyNode['id'], updates) + const node = nodeRef.current + updateNode(selectedId as AnyNode['id'], { + ...updates, + ...(node ? boxVentDimensionPatch({ ...node, ...updates }, updates) : {}), + }) useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId) }, [selectedId, updateNode], @@ -137,6 +150,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'} - /> +
+ {(['box', 'cap', 'dome'] as const).map((style) => ( + + ))} +
previewProp({ width: v })} onCommit={(v) => handleUpdate({ width: v })} @@ -172,7 +200,6 @@ export default function BoxVentPanel() { /> previewProp({ depth: v })} onCommit={(v) => handleUpdate({ depth: v })} @@ -183,8 +210,7 @@ export default function BoxVentPanel() { value={node.depth} /> previewProp({ height: v })} onCommit={(v) => handleUpdate({ height: v })} @@ -194,6 +220,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 +232,6 @@ export default function BoxVentPanel() { {node.style === 'cap' && ( previewProp({ hoodOverhang: v })} onCommit={(v) => handleUpdate({ hoodOverhang: v })} @@ -270,7 +300,7 @@ export default function BoxVentPanel() { value={node.capHeight ?? 0.07} /> previewProp({ capGap: v })} @@ -311,7 +341,6 @@ export default function BoxVentPanel() { /> previewProp({ hoodOverhang: v })} onCommit={(v) => handleUpdate({ hoodOverhang: v })} @@ -323,21 +352,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 +419,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..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' @@ -20,7 +22,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,26 +57,27 @@ 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 ( { /* disabled — see component-level note */ }} /> - - + + ) diff --git a/packages/nodes/src/box-vent/renderer.tsx b/packages/nodes/src/box-vent/renderer.tsx index 83e078df5b..ce333a31fb 100644 --- a/packages/nodes/src/box-vent/renderer.tsx +++ b/packages/nodes/src/box-vent/renderer.tsx @@ -12,14 +12,15 @@ import { type ColorPreset, 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 { accessoryFinish } from '../shared/accessory-finish' +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]]) @@ -118,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) @@ -127,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/box-vent/tool.test.tsx b/packages/nodes/src/box-vent/tool.test.tsx new file mode 100644 index 0000000000..ee958546d2 --- /dev/null +++ b/packages/nodes/src/box-vent/tool.test.tsx @@ -0,0 +1,118 @@ +import { expect, test } from 'bun:test' +import { + BuildingNode, + createSceneApi, + emitter, + LevelNode, + nodeRegistry, + RoofNode, + RoofSegmentNode, + registerNode, + sceneRegistry, + useScene, +} from '@pascal-app/core' +import { + EDITOR_LAYER, + RegistryToolProvider, + 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( + false, + sceneApi: createSceneApi(useScene), + selectNode: () => {}, + unit: 'metric', + }} + > + + , + ) + try { + const event = { + node: roof, + position: [0.13, 3, 3.13] as [number, number, number], + 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') + 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) + 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..930e8df4a0 100644 --- a/packages/nodes/src/box-vent/tool.tsx +++ b/packages/nodes/src/box-vent/tool.tsx @@ -1,160 +1,165 @@ 'use client' +import { type AnyNodeId, BoxVentNode, emitter, type RoofEvent } 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, + useRegistryToolContext, +} 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 { sceneApi } = useRegistryToolContext() 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] }) + sceneApi.upsert(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, sceneApi]) return ( <> } + ghost={} onInvalidTarget={() => { - setPreviewPos(null) - setPreviewSurfaceQuat(null) + setPreview(null) + usePlacementPreview.getState().clear() clearRoofSurfacePlacementGuides() }} /> - {activeBuildingId && previewPos && previewSurfaceQuat && ( - - - - + {preview && ( + + + + @@ -162,5 +167,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..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' @@ -48,40 +49,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,16 +57,26 @@ const ChimneyPreview = ({ geo.cap?.dispose() geo.flues?.dispose() geo.cricket?.dispose() + geo.bands?.dispose() }, [geo], ) return ( - - {}} /> - {geo.cap && {}} />} - {geo.flues && {}} />} - {geo.cricket && {}} />} + + {}} /> + {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..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' @@ -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!) @@ -190,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 @@ -212,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/chimney/tool.tsx b/packages/nodes/src/chimney/tool.tsx index a6f91cc6f9..1be50aa53b 100644 --- a/packages/nodes/src/chimney/tool.tsx +++ b/packages/nodes/src/chimney/tool.tsx @@ -8,9 +8,15 @@ import { type RoofNode, type RoofSegmentNode, sceneRegistry, - useScene, } from '@pascal-app/core' -import { triggerSFX, usePlacementPreview } from '@pascal-app/editor' +import { + isGridSnapActive, + isMagneticSnapActive, + triggerSFX, + useEditor, + usePlacementPreview, + useRegistryToolContext, +} 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' /** @@ -43,6 +51,7 @@ type SegmentTransform = { } const ChimneyTool = () => { + const { sceneApi } = useRegistryToolContext() const activeBuildingId = useViewer((s) => s.selection.buildingId) const setSelection = useViewer((s) => s.setSelection) @@ -55,6 +64,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 +104,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,7 +165,29 @@ const ChimneyTool = () => { event.position[2], ) if (!hit) return - const state = useScene.getState() + 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 chimney = ChimneyNode.parse({ ...chimneyDefinition.defaults(), @@ -142,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() @@ -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) @@ -164,7 +228,7 @@ const ChimneyTool = () => { if (placementPreview.node?.id === previewNode.id) placementPreview.clear() clearRoofSurfacePlacementGuides() } - }, [activeBuildingId, setSelection, previewNode]) + }, [activeBuildingId, setSelection, previewNode, sceneApi]) return ( <> @@ -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..c403620b7c 100644 --- a/packages/nodes/src/cupola/geometry.ts +++ b/packages/nodes/src/cupola/geometry.ts @@ -1,5 +1,6 @@ -import type { CupolaNode } from '@pascal-app/core' +import type { CupolaNode, RoofSegmentNode } from '@pascal-app/core' import * as THREE from 'three' +import { finishAccessoryNormals } from '../shared/accessory-normals' import { copyUvToSecondaryChannel, cumulativeProfileDistances, @@ -7,6 +8,8 @@ import { planarMetricUvs, } from '../shared/primitive-uv' +import { cupolaMount } from './placement' + export const CUPOLA_MATERIAL_INDEX = { base: 0, body: 1, @@ -33,7 +36,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 { +function buildUnsmoothedCupolaGeometry( + 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 +50,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 +65,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 +112,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 +161,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 +187,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 +202,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 +256,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 @@ -451,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/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..982899a892 100644 --- a/packages/nodes/src/cupola/preview.tsx +++ b/packages/nodes/src/cupola/preview.tsx @@ -1,5 +1,8 @@ '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' import { INVALID_GHOST_COLOR } from '../shared/ghost-materials' @@ -13,10 +16,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,26 +55,27 @@ 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 ( { /* disabled — see component-level note */ }} /> - - + + ) diff --git a/packages/nodes/src/cupola/renderer.tsx b/packages/nodes/src/cupola/renderer.tsx index 40d67042b4..6a8d73df7c 100644 --- a/packages/nodes/src/cupola/renderer.tsx +++ b/packages/nodes/src/cupola/renderer.tsx @@ -12,14 +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 { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' +import { accessoryFinish } from '../shared/accessory-finish' 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,19 +57,23 @@ 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 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) @@ -79,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')] }, [ @@ -96,8 +106,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..e798ad1e03 --- /dev/null +++ b/packages/nodes/src/cupola/tool.test.tsx @@ -0,0 +1,103 @@ +import { expect, test } from 'bun:test' +import { + BuildingNode, + createSceneApi, + emitter, + LevelNode, + nodeRegistry, + RoofNode, + RoofSegmentNode, + registerNode, + sceneRegistry, + useScene, +} from '@pascal-app/core' +import { RegistryToolProvider, 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( + false, + sceneApi: createSceneApi(useScene), + selectNode: () => {}, + unit: 'metric', + }} + > + + , + ) + 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..75c12830cc 100644 --- a/packages/nodes/src/cupola/tool.tsx +++ b/packages/nodes/src/cupola/tool.tsx @@ -1,149 +1,159 @@ 'use client' +import { type AnyNodeId, CupolaNode, emitter, type RoofEvent } 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, + useRegistryToolContext, +} 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 { sceneApi } = useRegistryToolContext() 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] }) + sceneApi.upsert(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, sceneApi]) return ( <> } + ghost={} onInvalidTarget={() => { - setPreviewPos(null) - setPreviewSurfaceQuat(null) + setPreview(null) + usePlacementPreview.getState().clear() clearRoofSurfacePlacementGuides() }} /> - {activeBuildingId && previewPos && previewSurfaceQuat && ( - - - - + {preview && ( + + + + @@ -151,5 +161,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/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/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/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..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, @@ -35,7 +36,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) @@ -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 { @@ -223,7 +224,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 +232,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,10 +249,25 @@ 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) return slab } + +export function buildDownspoutGeometry( + node: DownspoutNode, + routing?: DownspoutRouting | null, +): THREE.BufferGeometry { + return finishAccessoryNormals(buildUnsmoothedDownspoutGeometry(node, routing)) +} 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/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/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/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-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/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..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) @@ -97,8 +98,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 +136,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 +152,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 +186,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 +239,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 +259,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 +323,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 +358,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( @@ -558,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/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..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' @@ -37,26 +39,27 @@ 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 ( { /* disabled — see component-level note */ }} /> - - + + ) diff --git a/packages/nodes/src/eyebrow-vent/renderer.tsx b/packages/nodes/src/eyebrow-vent/renderer.tsx index fe9bfe2ef4..fe89b95663 100644 --- a/packages/nodes/src/eyebrow-vent/renderer.tsx +++ b/packages/nodes/src/eyebrow-vent/renderer.tsx @@ -12,14 +12,15 @@ import { type ColorPreset, 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 { accessoryFinish } from '../shared/accessory-finish' +import { surfaceQuatFromNormal } from '../shared/roof-surface' import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip' import { buildEyebrowVentGeometry } from './geometry' @@ -67,12 +68,16 @@ 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]]) 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) @@ -81,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/eyebrow-vent/tool.test.tsx b/packages/nodes/src/eyebrow-vent/tool.test.tsx new file mode 100644 index 0000000000..207cebb424 --- /dev/null +++ b/packages/nodes/src/eyebrow-vent/tool.test.tsx @@ -0,0 +1,105 @@ +import { expect, test } from 'bun:test' +import { + BuildingNode, + createSceneApi, + emitter, + LevelNode, + nodeRegistry, + RoofNode, + RoofSegmentNode, + registerNode, + sceneRegistry, + useScene, +} from '@pascal-app/core' +import { RegistryToolProvider, 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( + false, + sceneApi: createSceneApi(useScene), + selectNode: () => {}, + unit: 'metric', + }} + > + + , + ) + 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..497a0dc858 100644 --- a/packages/nodes/src/eyebrow-vent/tool.tsx +++ b/packages/nodes/src/eyebrow-vent/tool.tsx @@ -1,155 +1,165 @@ 'use client' +import { type AnyNodeId, EyebrowVentNode, emitter, type RoofEvent } from '@pascal-app/core' import { - type AnyNodeId, - 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' + isGridSnapActive, + isMagneticSnapActive, + triggerSFX, + useEditor, + usePlacementPreview, + useRegistryToolContext, +} 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 { sceneApi } = useRegistryToolContext() 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] }) + sceneApi.upsert(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, sceneApi]) return ( <> } + ghost={} onInvalidTarget={() => { - setPreviewPos(null) - setPreviewSurfaceQuat(null) + setPreview(null) + usePlacementPreview.getState().clear() clearRoofSurfacePlacementGuides() }} /> - {activeBuildingId && previewPos && previewSurfaceQuat && ( - - - - + {preview && ( + + + + @@ -157,5 +167,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..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 { @@ -434,7 +435,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 +475,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 +500,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 +612,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[] = [] @@ -761,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/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/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/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/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..8ae8def16d 100644 --- a/packages/nodes/src/hvac-equipment/definition.ts +++ b/packages/nodes/src/hvac-equipment/definition.ts @@ -1,6 +1,8 @@ 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' import { hvacEquipmentParametrics } from './parametrics' import { getHvacEquipmentPorts } from './ports' import { HvacEquipmentNode } from './schema' @@ -33,6 +35,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, @@ -44,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] }, @@ -61,11 +74,18 @@ export const hvacEquipmentDefinition: NodeDefinition = }, parametrics: hvacEquipmentParametrics, + toolOptions: hvacEquipmentToolOptions, geometry: buildHvacEquipmentGeometry, geometryKey: (n) => JSON.stringify([ + n.slots, n.equipmentType, + n.supplyFace, + n.returnFace, + n.serviceFace, + n.suctionDiameter, + n.liquidDiameter, n.width, n.depth, n.height, @@ -101,6 +121,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..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' @@ -29,7 +32,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 +43,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' @@ -66,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) @@ -86,11 +87,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 +105,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 +416,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 +428,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 +445,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 +520,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 +534,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 +758,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 +789,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 @@ -860,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 new file mode 100644 index 0000000000..c58d2dea8b --- /dev/null +++ b/packages/nodes/src/hvac-equipment/hvac-equipment.test.ts @@ -0,0 +1,171 @@ +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, Mesh, 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 }) + } +}) + +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/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..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)) ) } @@ -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..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.liquidDiameter, 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 @@ -78,6 +84,7 @@ export const linesetDefinition: NodeDefinition = { direction: unit(first, second), diameter, system: 'refrigerant', + connectionType: 'suction', }, { id: 'end', @@ -85,6 +92,7 @@ export const linesetDefinition: NodeDefinition = { direction: unit(last, prev), diameter, system: 'refrigerant', + connectionType: 'suction', }, ] }, @@ -121,9 +129,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 +139,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/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/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..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 @@ -77,6 +80,7 @@ export const liquidLineDefinition: NodeDefinition = { direction: unit(first, second), diameter, system: 'refrigerant', + connectionType: 'liquid', }, { id: 'end', @@ -84,6 +88,7 @@ export const liquidLineDefinition: NodeDefinition = { direction: unit(last, prev), diameter, system: 'refrigerant', + connectionType: 'liquid', }, ] }, 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/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/__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/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..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 { @@ -71,7 +72,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 +226,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 +424,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, @@ -789,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/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..5e5c62fc9e 100644 --- a/packages/nodes/src/ridge-vent/preview.tsx +++ b/packages/nodes/src/ridge-vent/preview.tsx @@ -1,16 +1,36 @@ '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' 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,26 +51,27 @@ 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 ( { /* see box-vent preview note */ }} /> - - + + ) diff --git a/packages/nodes/src/ridge-vent/renderer.tsx b/packages/nodes/src/ridge-vent/renderer.tsx index 78f0fb200e..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,7 +22,7 @@ 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 { accessoryFinish } from '../shared/accessory-finish' 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, @@ -196,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) } @@ -215,6 +210,7 @@ const RidgeVentRenderer = ({ node: storeNode }: { node: RidgeVentNode }) => { colorPreset, sceneTheme, shading, + node.slots, node.material, node.materialPreset, segment, @@ -231,7 +227,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 +260,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..89f1f13aaf --- /dev/null +++ b/packages/nodes/src/ridge-vent/tool.test.tsx @@ -0,0 +1,101 @@ +import { expect, test } from 'bun:test' +import { + BuildingNode, + createSceneApi, + emitter, + LevelNode, + nodeRegistry, + RoofNode, + RoofSegmentNode, + registerNode, + sceneRegistry, + useScene, +} from '@pascal-app/core' +import { RegistryToolProvider, 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( + false, + sceneApi: createSceneApi(useScene), + selectNode: () => {}, + unit: 'metric', + }} + > + + , + ) + 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..0f8e900728 100644 --- a/packages/nodes/src/ridge-vent/tool.tsx +++ b/packages/nodes/src/ridge-vent/tool.tsx @@ -5,187 +5,154 @@ import { emitter, RidgeVentNode, type RoofEvent, - type RoofNode, - sceneRegistry, - useScene, + type RoofSegmentNode, } from '@pascal-app/core' -import { triggerSFX } from '@pascal-app/editor' +import { + isGridSnapActive, + triggerSFX, + useEditor, + usePlacementPreview, + useRegistryToolContext, +} 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 { sceneApi } = useRegistryToolContext() 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, + 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: [ - snap.localX, - getRoofTopSurfaceY(snap.localX, snap.localZ, hit.segment), - snap.localZ, - ], - footprint: roofSurfaceFootprintFromNode(previewNode), - mode: 'linear-edge', + 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] }) + sceneApi.upsert(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, sceneApi]) 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/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/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/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/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..f4ec714707 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,58 @@ 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() +}) + +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/__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 a7549d3e3b..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' @@ -219,6 +220,7 @@ const skylightHandles: HandleDescriptor[] = [ export const skylightDefinition: NodeDefinition = { kind: 'skylight', schemaVersion: 1, + snapProfile: 'item', schema: SkylightNode, category: 'structure', surfaceRole: 'glazing', @@ -233,6 +235,7 @@ export const skylightDefinition: NodeDefinition = { }, capabilities: { + paint: skylightPaint, exterior: true, selectable: { hitVolume: 'bbox' }, duplicable: true, @@ -268,6 +271,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/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/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..58c33bc321 100644 --- a/packages/nodes/src/skylight/preview.tsx +++ b/packages/nodes/src/skylight/preview.tsx @@ -1,9 +1,12 @@ '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' +import { HingedGlass, LanternGlass, SlidingGlass } from './renderer' import type { SkylightNode } from './schema' const ghostMaterial = new THREE.MeshStandardMaterial({ @@ -27,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( @@ -49,20 +57,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..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' @@ -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, @@ -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/skylight/tool.tsx b/packages/nodes/src/skylight/tool.tsx index c23fbfc37b..d491cd53a0 100644 --- a/packages/nodes/src/skylight/tool.tsx +++ b/packages/nodes/src/skylight/tool.tsx @@ -1,159 +1,162 @@ 'use client' +import { type AnyNodeId, emitter, type RoofEvent, SkylightNode } 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, + useRegistryToolContext, +} 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 { sceneApi } = useRegistryToolContext() 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] }) + sceneApi.upsert(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, sceneApi]) 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 +164,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/__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/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..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' @@ -228,6 +229,7 @@ const solarPanelHandles: HandleDescriptor[] = [ export const solarPanelDefinition: NodeDefinition = { kind: 'solar-panel', schemaVersion: 1, + snapProfile: 'item', schema: SolarPanelNode, category: 'structure', surfaceRole: 'roof', @@ -236,12 +238,16 @@ 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 }, capabilities: { + paint: solarPanelPaint, exterior: true, selectable: { hitVolume: 'bbox' }, duplicable: true, @@ -269,6 +275,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..7e0eb36fe7 100644 --- a/packages/nodes/src/solar-panel/geometry.ts +++ b/packages/nodes/src/solar-panel/geometry.ts @@ -1,4 +1,5 @@ -import type { RoofSegmentNode, SolarPanelNode } from '@pascal-app/core' +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 @@ -104,6 +106,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 +130,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,28 +139,50 @@ 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) - // 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 + glass.translate(cx, y + frameDepth * 0.1, cz) + applyWorldScaleBoxUVs(glass, glassW, frameDepth * 0.6, glassH) 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) @@ -171,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() @@ -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/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/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..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' @@ -42,6 +44,8 @@ const SolarPanelPreview = ({ node, invalid }: { node: SolarPanelNode; invalid?: node.frameThickness, node.frameDepth, node.standoffHeight, + node.mountingType, + node.tiltAngle, ], ) @@ -50,13 +54,18 @@ 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..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' @@ -112,6 +112,8 @@ const SolarPanelRenderer = ({ node: storeNode }: { node: SolarPanelNode }) => { node.frameThickness, node.frameDepth, node.standoffHeight, + node.mountingType, + node.tiltAngle, ], ) @@ -122,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/solar-panel/tool.test.tsx b/packages/nodes/src/solar-panel/tool.test.tsx new file mode 100644 index 0000000000..13239682b9 --- /dev/null +++ b/packages/nodes/src/solar-panel/tool.test.tsx @@ -0,0 +1,91 @@ +import { expect, spyOn, test } from 'bun:test' +import { + BuildingNode, + createSceneApi, + emitter, + LevelNode, + RoofNode, + RoofSegmentNode, + sceneRegistry, + useScene, +} from '@pascal-app/core' +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' +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( + false, + sceneApi: createSceneApi(useScene), + selectNode: () => {}, + unit: 'metric', + }} + > + + , + ) + 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..e9a6817f8e 100644 --- a/packages/nodes/src/solar-panel/tool.tsx +++ b/packages/nodes/src/solar-panel/tool.tsx @@ -1,181 +1,166 @@ 'use client' +import { type AnyNodeId, emitter, type RoofEvent, SolarPanelNode } 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, + useRegistryToolContext, +} 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 { sceneApi } = useRegistryToolContext() 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(sceneApi.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(sceneApi.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] }) + sceneApi.upsert(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, sceneApi]) 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 +168,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..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[] = [] @@ -93,21 +94,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 +154,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 +349,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 ─────────────────────────────────────────────── @@ -451,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/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..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' @@ -38,26 +40,27 @@ 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 ( { /* disabled — see component-level note */ }} /> - - + + ) diff --git a/packages/nodes/src/turbine-vent/renderer.tsx b/packages/nodes/src/turbine-vent/renderer.tsx index 992bb9c8f6..2ae7911009 100644 --- a/packages/nodes/src/turbine-vent/renderer.tsx +++ b/packages/nodes/src/turbine-vent/renderer.tsx @@ -12,7 +12,7 @@ import { type ColorPreset, createMaterial, createMaterialFromPresetRef, - createSurfaceRoleMaterial, + getRoofOuterSurfaceFrameAtPoint, resolveMaterialRef, useNodeEvents, useViewer, @@ -20,7 +20,8 @@ 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 { accessoryFinish } from '../shared/accessory-finish' +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,12 +97,23 @@ 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]]) 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/packages/nodes/src/turbine-vent/tool.test.tsx b/packages/nodes/src/turbine-vent/tool.test.tsx new file mode 100644 index 0000000000..93facd7d92 --- /dev/null +++ b/packages/nodes/src/turbine-vent/tool.test.tsx @@ -0,0 +1,106 @@ +import { expect, test } from 'bun:test' +import { + BuildingNode, + createSceneApi, + emitter, + LevelNode, + nodeRegistry, + RoofNode, + RoofSegmentNode, + registerNode, + sceneRegistry, + useScene, +} from '@pascal-app/core' +import { RegistryToolProvider, 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( + false, + sceneApi: createSceneApi(useScene), + selectNode: () => {}, + unit: 'metric', + }} + > + + , + ) + 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..c6b0bd6182 100644 --- a/packages/nodes/src/turbine-vent/tool.tsx +++ b/packages/nodes/src/turbine-vent/tool.tsx @@ -1,155 +1,165 @@ 'use client' +import { type AnyNodeId, emitter, type RoofEvent, TurbineVentNode } from '@pascal-app/core' 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' + isGridSnapActive, + isMagneticSnapActive, + triggerSFX, + useEditor, + usePlacementPreview, + useRegistryToolContext, +} 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 { sceneApi } = useRegistryToolContext() 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] }) + sceneApi.upsert(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, sceneApi]) return ( <> } + ghost={} onInvalidTarget={() => { - setPreviewPos(null) - setPreviewSurfaceQuat(null) + setPreview(null) + usePlacementPreview.getState().clear() clearRoofSurfacePlacementGuides() }} /> - {activeBuildingId && previewPos && previewSurfaceQuat && ( - - - - + {preview && ( + + + + @@ -157,5 +167,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/pr-1125-verification/README.md b/wiki/design/pr-1125-verification/README.md new file mode 100644 index 0000000000..77cf5b4a3f --- /dev/null +++ b/wiki/design/pr-1125-verification/README.md @@ -0,0 +1,43 @@ +# 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) + +## 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/box-vent.webp b/wiki/design/pr-1125-verification/box-vent.webp new file mode 100644 index 0000000000..f99d5731f3 Binary files /dev/null and b/wiki/design/pr-1125-verification/box-vent.webp differ diff --git a/wiki/design/pr-1125-verification/build-search.webp b/wiki/design/pr-1125-verification/build-search.webp new file mode 100644 index 0000000000..5fbbcd6397 Binary files /dev/null and b/wiki/design/pr-1125-verification/build-search.webp differ diff --git a/wiki/design/pr-1125-verification/canopy.webp b/wiki/design/pr-1125-verification/canopy.webp new file mode 100644 index 0000000000..9f48abb18f Binary files /dev/null and b/wiki/design/pr-1125-verification/canopy.webp differ diff --git a/wiki/design/pr-1125-verification/hvac.webp b/wiki/design/pr-1125-verification/hvac.webp new file mode 100644 index 0000000000..a28415beca Binary files /dev/null and b/wiki/design/pr-1125-verification/hvac.webp differ 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 0000000000..6e6eb0643d Binary files /dev/null and b/wiki/design/pr-1125-verification/legacy.webp differ 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 0000000000..b225826515 Binary files /dev/null and b/wiki/design/pr-1125-verification/paint-persistence.webp differ 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. +