From a8d3b372bf82de6d2992529fdb465c908d8127b2 Mon Sep 17 00:00:00 2001 From: Mike Vickers Date: Fri, 10 Jul 2026 20:17:19 -0400 Subject: [PATCH] feat: add Fireplace node with animated procedural fire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New architectural fireplace node for the Pascal 3D building editor: - 4 styles: wall, freestanding, corner, double-sided - Parametric mantel, hearth, surround, firebox with inspector controls - Animated instanced fire particle system with flickering point light - 5 fire intensity levels (none → roaring), 4 fire colors - Placement tool with grid snap, alignment guides, SFX - Translucent ghost preview during placement - Resize/rotate/move handles in 3D viewport Registered through the builtin plugin via the same NodeDefinition contract as all other node kinds. Follows the three-checkbox model: geometry (pure builder) + renderer (custom React with useFrame) + tool (lazy placement component). --- apps/ifc-converter/next-env.d.ts | 2 +- packages/core/src/events/bus.ts | 3 + packages/core/src/index.ts | 1 + packages/core/src/schema/index.ts | 6 + packages/core/src/schema/nodes/fireplace.ts | 69 ++++++ packages/core/src/schema/types.ts | 2 + packages/nodes/src/fireplace/definition.ts | 162 ++++++++++++++ packages/nodes/src/fireplace/geometry.ts | 204 ++++++++++++++++++ packages/nodes/src/fireplace/index.ts | 4 + packages/nodes/src/fireplace/parametrics.ts | 55 +++++ packages/nodes/src/fireplace/preview.tsx | 34 +++ packages/nodes/src/fireplace/renderer.tsx | 224 ++++++++++++++++++++ packages/nodes/src/fireplace/schema.ts | 1 + packages/nodes/src/fireplace/tool.tsx | 142 +++++++++++++ packages/nodes/src/index.ts | 2 + 15 files changed, 910 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/schema/nodes/fireplace.ts create mode 100644 packages/nodes/src/fireplace/definition.ts create mode 100644 packages/nodes/src/fireplace/geometry.ts create mode 100644 packages/nodes/src/fireplace/index.ts create mode 100644 packages/nodes/src/fireplace/parametrics.ts create mode 100644 packages/nodes/src/fireplace/preview.tsx create mode 100644 packages/nodes/src/fireplace/renderer.tsx create mode 100644 packages/nodes/src/fireplace/schema.ts create mode 100644 packages/nodes/src/fireplace/tool.tsx diff --git a/apps/ifc-converter/next-env.d.ts b/apps/ifc-converter/next-env.d.ts index c4b7818fb..9edff1c7c 100644 --- a/apps/ifc-converter/next-env.d.ts +++ b/apps/ifc-converter/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 42e6ab9ee..d557f3b15 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -17,6 +17,7 @@ import type { ElevatorNode, EyebrowVentNode, FenceNode, + FireplaceNode, GuideNode, GutterNode, HvacEquipmentNode, @@ -86,6 +87,7 @@ export interface NodeEvent { export type WallEvent = NodeEvent export type FenceEvent = NodeEvent +export type FireplaceEvent = NodeEvent export type ItemEvent = NodeEvent export type SiteEvent = NodeEvent export type BuildingEvent = NodeEvent @@ -254,6 +256,7 @@ type RoomPresetEvents = { type EditorEvents = GridEvents & NodeEvents<'wall', WallEvent> & NodeEvents<'fence', FenceEvent> & + NodeEvents<'fireplace', FireplaceEvent> & NodeEvents<'item', ItemEvent> & NodeEvents<'site', SiteEvent> & NodeEvents<'building', BuildingEvent> & diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 823470cfb..fdc857477 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -11,6 +11,7 @@ export type { ElevatorEvent, EventSuffix, FenceEvent, + FireplaceEvent, GridEvent, GuideEvent, GutterEvent, diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index b0f0d4cd3..b4d624c74 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -70,6 +70,12 @@ export { } from './nodes/elevator' export { EyebrowVentNode } from './nodes/eyebrow-vent' export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence' +export { + FireplaceMaterialRole, + FireplaceNode, + FireplaceStyle, + FireStyle, +} from './nodes/fireplace' export { GuideNode, GuideScaleReference } from './nodes/guide' export { GutterNode, GutterOutlet } from './nodes/gutter' export { HvacEquipmentNode } from './nodes/hvac-equipment' diff --git a/packages/core/src/schema/nodes/fireplace.ts b/packages/core/src/schema/nodes/fireplace.ts new file mode 100644 index 000000000..b81c7d1c1 --- /dev/null +++ b/packages/core/src/schema/nodes/fireplace.ts @@ -0,0 +1,69 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' + +export const FireplaceMaterialRole = z.enum(['mantel', 'surround', 'hearth', 'firebox']) +export type FireplaceMaterialRole = z.infer + +export const FireplaceStyle = z.enum(['wall', 'freestanding', 'corner', 'double-sided']) +export type FireplaceStyle = z.infer + +export const FireStyle = z.enum(['none', 'small', 'medium', 'large', 'roaring']) +export type FireStyle = z.infer + +export const FireplaceNode = BaseNode.extend({ + id: objectId('fireplace'), + type: nodeType('fireplace'), + + material: MaterialSchema.optional(), + materialPreset: z.string().optional(), + mantelMaterial: MaterialSchema.optional(), + mantelMaterialPreset: z.string().optional(), + hearthMaterial: MaterialSchema.optional(), + hearthMaterialPreset: z.string().optional(), + fireboxMaterial: MaterialSchema.optional(), + fireboxMaterialPreset: z.string().optional(), + + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.number().default(0), + + style: FireplaceStyle.default('wall'), + + width: z.number().min(0.6).max(4).default(1.5), + height: z.number().min(0.8).max(4).default(2.2), + depth: z.number().min(0.3).max(1.5).default(0.6), + + fireboxWidth: z.number().min(0.3).max(3).default(0.9), + fireboxHeight: z.number().min(0.3).max(3).default(0.7), + fireboxDepth: z.number().min(0.2).max(1.2).default(0.4), + fireboxSillHeight: z.number().min(0).max(1.5).default(0.3), + + mantelHeight: z.number().min(0.05).max(0.5).default(0.12), + mantelOverhang: z.number().min(0).max(0.3).default(0.08), + mantelThickness: z.number().min(0.03).max(0.2).default(0.06), + mantelWidth: z.number().min(0).max(1).default(0.1), + + hearthDepth: z.number().min(0).max(0.8).default(0.35), + hearthHeight: z.number().min(0.02).max(0.2).default(0.05), + hearthWidth: z.number().min(0).max(1.5).default(0.15), + + surroundWidth: z.number().min(0.05).max(0.5).default(0.15), + lintelHeight: z.number().min(0.05).max(0.4).default(0.12), + + cornerAngle: z.number().min(30).max(90).default(45), + + fire: FireStyle.default('medium'), + fireColor: z.enum(['orange', 'amber', 'blue', 'white']).default('orange'), +}).describe( + dedent` + Fireplace — an architectural fireplace with mantel, hearth, surround, + firebox, and optional animated fire. Hosted on a level; placed against + a wall (wall), free in space (freestanding), in a corner (corner), or + penetrating a wall (double-sided). The fire is a procedural particle + system rendered in the viewer — toggle with the \`fire\` field. + `, +) + +export type FireplaceNode = z.infer +export type FireplaceNodeId = FireplaceNode['id'] diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index d404367b0..821817f96 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -14,6 +14,7 @@ import { DuctTerminalNode } from './nodes/duct-terminal' import { ElevatorNode } from './nodes/elevator' import { EyebrowVentNode } from './nodes/eyebrow-vent' import { FenceNode } from './nodes/fence' +import { FireplaceNode } from './nodes/fireplace' import { GuideNode } from './nodes/guide' import { GutterNode } from './nodes/gutter' import { HvacEquipmentNode } from './nodes/hvac-equipment' @@ -83,6 +84,7 @@ export const AnyNode = z.discriminatedUnion('type', [ PipeSegmentNode, PipeFittingNode, PipeTrapNode, + FireplaceNode, ]) export type AnyNode = z.infer diff --git a/packages/nodes/src/fireplace/definition.ts b/packages/nodes/src/fireplace/definition.ts new file mode 100644 index 000000000..6c28b9aad --- /dev/null +++ b/packages/nodes/src/fireplace/definition.ts @@ -0,0 +1,162 @@ +import type { + FireplaceNode as FireplaceNodeType, + HandleDescriptor, + NodeDefinition, +} from '@pascal-app/core' +import { buildFireplaceGeometry } from './geometry' +import { fireplaceParametrics } from './parametrics' +import { FireplaceNode } from './schema' + +const SIDE_HANDLE_OFFSET = 0.18 +const HEIGHT_HANDLE_OFFSET = 0.22 +const ROTATE_CORNER_OFFSET = 0.32 +const ROTATE_RING_OFFSET = 0.04 +const MOVE_FRONT_OFFSET = 0.35 + +const MIN_FP_WIDTH = 0.6 +const MIN_FP_DEPTH = 0.3 +const MIN_FP_HEIGHT = 0.8 + +function fireplaceWidthHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'x', + anchor: 'center', + min: MIN_FP_WIDTH, + currentValue: (n) => n.width, + apply: (_n, newValue) => ({ width: newValue }), + placement: { + position: (n) => [n.width / 2 + SIDE_HANDLE_OFFSET, n.height / 2, 0], + }, + } +} + +function fireplaceDepthHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'z', + anchor: 'center', + min: MIN_FP_DEPTH, + currentValue: (n) => n.depth, + apply: (_n, newValue) => ({ depth: newValue }), + placement: { + position: (n) => [0, n.height / 2, n.depth / 2 + SIDE_HANDLE_OFFSET], + }, + } +} + +function fireplaceHeightHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'y', + anchor: 'min', + min: MIN_FP_HEIGHT, + currentValue: (n) => n.height, + apply: (_n, newValue) => ({ height: newValue }), + placement: { + position: (n) => [0, n.height + HEIGHT_HANDLE_OFFSET, 0], + }, + } +} + +function fireplaceRotateHandle(): HandleDescriptor { + return { + kind: 'arc-resize', + axis: 'angular', + shape: 'rotate', + apply: (initial, delta) => ({ + rotation: (initial.rotation ?? 0) - delta, + }), + placement: { + position: (n) => [n.width / 2, n.height / 2, n.depth / 2 + ROTATE_CORNER_OFFSET], + rotationY: () => -Math.PI / 4, + }, + decoration: { + kind: 'ring', + radius: (n) => Math.hypot(n.width / 2, n.depth / 2) + ROTATE_RING_OFFSET, + y: (n) => n.height / 2, + }, + } +} + +function fireplaceMoveHandle(): HandleDescriptor { + return { + kind: 'translate', + placement: { + position: (n) => [0, 0.02, n.depth / 2 + MOVE_FRONT_OFFSET], + }, + apply: (_n, pos) => ({ position: [pos[0], pos[1], pos[2]] }), + snapExtents: (n) => [n.width, n.depth], + } +} + +function fireplaceHandles(_node: FireplaceNodeType): HandleDescriptor[] { + return [ + fireplaceWidthHandle(), + fireplaceDepthHandle(), + fireplaceHeightHandle(), + fireplaceRotateHandle(), + fireplaceMoveHandle(), + ] +} + +export const fireplaceDefinition: NodeDefinition = { + kind: 'fireplace', + snapProfile: 'item', + facingIndicator: true, + schemaVersion: 1, + schema: FireplaceNode, + category: 'furnish', + surfaceRole: 'furnishing', + + defaults: () => ({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + position: [0, 0, 0], + rotation: 0, + style: 'wall', + width: 1.5, + height: 2.2, + depth: 0.6, + fireboxWidth: 0.9, + fireboxHeight: 0.7, + fireboxDepth: 0.4, + fireboxSillHeight: 0.3, + mantelHeight: 0.12, + mantelOverhang: 0.08, + mantelThickness: 0.06, + mantelWidth: 0.1, + hearthDepth: 0.35, + hearthHeight: 0.05, + hearthWidth: 0.15, + surroundWidth: 0.15, + lintelHeight: 0.12, + cornerAngle: 45, + fire: 'medium', + fireColor: 'orange', + }), + + capabilities: { + movable: { axes: ['x', 'z'], gridSnap: true }, + rotatable: { + axes: ['y'], + snapAngles: [0, Math.PI / 4, Math.PI / 2, (3 * Math.PI) / 4, Math.PI], + }, + selectable: { hitVolume: 'bbox' }, + duplicable: true, + deletable: true, + }, + + handles: fireplaceHandles, + parametrics: fireplaceParametrics, + geometry: buildFireplaceGeometry, + renderer: { kind: 'parametric', module: () => import('./renderer') }, + tool: () => import('./tool'), + presentation: { + label: 'Fireplace', + icon: { kind: 'iconify', name: 'lucide:flame' }, + paletteSection: 'furnish', + }, +} diff --git a/packages/nodes/src/fireplace/geometry.ts b/packages/nodes/src/fireplace/geometry.ts new file mode 100644 index 000000000..9a1e0c32b --- /dev/null +++ b/packages/nodes/src/fireplace/geometry.ts @@ -0,0 +1,204 @@ +import { type GeometryContext, getMaterialPresetByRef } from '@pascal-app/core' +import { + applyMaterialPresetToMaterials, + createDefaultMaterial, + createMaterial, + type RenderShading, +} from '@pascal-app/viewer' +import { BoxGeometry, Group, type Material, Mesh } from 'three' +import type { FireplaceNode } from './schema' + +const DEFAULT_SURROUND_COLOR = '#3a3a3a' +const DEFAULT_MANTEL_COLOR = '#4a3520' +const DEFAULT_HEARTH_COLOR = '#2a2a2a' +const DEFAULT_FIREBOX_COLOR = '#1a1a1a' + +function getMaterial( + node: FireplaceNode, + preset: string | undefined, + fallbackColor: string, + shading: RenderShading, +): Material { + if (preset) { + const presetObj = getMaterialPresetByRef(preset) + if (presetObj) { + const base = createDefaultMaterial('#ffffff', 0.5, shading) + applyMaterialPresetToMaterials(base, presetObj) + return base + } + } + if (node.material) return createMaterial(node.material, shading) + return createDefaultMaterial(fallbackColor, 0.7, shading) +} + +function getMantelMaterial(node: FireplaceNode, shading: RenderShading): Material { + if (node.mantelMaterialPreset) { + const presetObj = getMaterialPresetByRef(node.mantelMaterialPreset) + if (presetObj) { + const base = createDefaultMaterial('#ffffff', 0.5, shading) + applyMaterialPresetToMaterials(base, presetObj) + return base + } + } + if (node.mantelMaterial) return createMaterial(node.mantelMaterial, shading) + return createDefaultMaterial(DEFAULT_MANTEL_COLOR, 0.8, shading) +} + +function getHearthMaterial(node: FireplaceNode, shading: RenderShading): Material { + if (node.hearthMaterialPreset) { + const presetObj = getMaterialPresetByRef(node.hearthMaterialPreset) + if (presetObj) { + const base = createDefaultMaterial('#ffffff', 0.5, shading) + applyMaterialPresetToMaterials(base, presetObj) + return base + } + } + if (node.hearthMaterial) return createMaterial(node.hearthMaterial, shading) + return createDefaultMaterial(DEFAULT_HEARTH_COLOR, 0.8, shading) +} + +function getFireboxMaterial(node: FireplaceNode, shading: RenderShading): Material { + if (node.fireboxMaterialPreset) { + const presetObj = getMaterialPresetByRef(node.fireboxMaterialPreset) + if (presetObj) { + const base = createDefaultMaterial('#ffffff', 0.5, shading) + applyMaterialPresetToMaterials(base, presetObj) + return base + } + } + if (node.fireboxMaterial) return createMaterial(node.fireboxMaterial, shading) + return createDefaultMaterial(DEFAULT_FIREBOX_COLOR, 0.9, shading) +} + +export function buildFireplaceGeometry( + node: FireplaceNode, + ctx?: GeometryContext, + shading: RenderShading = 'rendered', +): Group { + const group = new Group() + group.name = 'fireplace-geometry' + + const surroundMat = getMaterial(node, node.materialPreset, DEFAULT_SURROUND_COLOR, shading) + const mantelMat = getMantelMaterial(node, shading) + const hearthMat = getHearthMaterial(node, shading) + const fireboxMat = getFireboxMaterial(node, shading) + + const { width, height, depth, style, cornerAngle } = node + const { + fireboxWidth, + fireboxHeight, + fireboxDepth, + fireboxSillHeight, + mantelHeight, + mantelOverhang, + mantelThickness, + hearthDepth, + hearthHeight, + hearthWidth, + surroundWidth, + lintelHeight, + } = node + + // Hearths — the floor-level stone slab extending forward of the firebox. + const hearthW = width + hearthWidth * 2 + const hearthMesh = new Mesh(new BoxGeometry(hearthW, hearthHeight, hearthDepth), hearthMat) + hearthMesh.name = 'fireplace-hearth' + hearthMesh.position.set(0, hearthHeight / 2, hearthDepth / 2) + group.add(hearthMesh) + + // Surround — the vertical structure around the firebox opening. + // Left + right pillars. + const pillarHeight = height - hearthHeight + const pillarY = hearthHeight + pillarHeight / 2 + + const leftPillar = new Mesh(new BoxGeometry(surroundWidth, pillarHeight, depth), surroundMat) + leftPillar.name = 'fireplace-surround-left' + leftPillar.position.set(-(width / 2 - surroundWidth / 2), pillarY, 0) + group.add(leftPillar) + + const rightPillar = new Mesh(new BoxGeometry(surroundWidth, pillarHeight, depth), surroundMat) + rightPillar.name = 'fireplace-surround-right' + rightPillar.position.set(width / 2 - surroundWidth / 2, pillarY, 0) + group.add(rightPillar) + + // Lintel — the horizontal beam above the firebox opening. + const lintelY = hearthHeight + fireboxSillHeight + fireboxHeight + lintelHeight / 2 + const lintel = new Mesh(new BoxGeometry(width, lintelHeight, depth), surroundMat) + lintel.name = 'fireplace-lintel' + lintel.position.set(0, lintelY, 0) + group.add(lintel) + + // Top filler — fills the gap between lintel top and the overall height. + const topFillerHeight = height - (fireboxSillHeight + fireboxHeight + lintelHeight) - hearthHeight + if (topFillerHeight > 0.01) { + const topFiller = new Mesh(new BoxGeometry(width, topFillerHeight, depth), surroundMat) + topFiller.name = 'fireplace-top' + topFiller.position.set( + 0, + hearthHeight + fireboxSillHeight + fireboxHeight + lintelHeight + topFillerHeight / 2, + 0, + ) + group.add(topFiller) + } + + // Firebox interior — a dark recessed box. Positioned behind the opening. + const fireboxY = hearthHeight + fireboxSillHeight + fireboxHeight / 2 + const fireboxZ = -depth / 2 + fireboxDepth / 2 + + // Firebox walls (back + top + sides) — all in dark firebox material. + const fireboxInterior = new Mesh( + new BoxGeometry(fireboxWidth, fireboxHeight, fireboxDepth), + fireboxMat, + ) + fireboxInterior.name = 'fireplace-firebox' + fireboxInterior.position.set(0, fireboxY, fireboxZ) + group.add(fireboxInterior) + + // Back wall of the firebox (thinner, dark). + const fireboxBack = new Mesh(new BoxGeometry(fireboxWidth, fireboxHeight, 0.02), fireboxMat) + fireboxBack.name = 'fireplace-firebox-back' + fireboxBack.position.set(0, fireboxY, -depth / 2 + 0.01) + group.add(fireboxBack) + + // Mantel shelf — the decorative top piece that overhangs the surround. + if (mantelHeight > 0) { + const mantelW = width + mantelOverhang * 2 + node.mantelWidth * 2 + const mantelD = depth + mantelOverhang + const mantel = new Mesh(new BoxGeometry(mantelW, mantelThickness, mantelD), mantelMat) + mantel.name = 'fireplace-mantel' + const mantelY = height - mantelThickness / 2 + mantel.position.set(0, mantelY, mantelOverhang / 2) + group.add(mantel) + } + + // Corner style — rotate the whole structure by cornerAngle. + if (style === 'corner') { + group.rotation.y = (cornerAngle * Math.PI) / 180 + } + + // Double-sided — add a second firebox opening on the back. + if (style === 'double-sided') { + const backFirebox = new Mesh( + new BoxGeometry(fireboxWidth, fireboxHeight, fireboxDepth), + fireboxMat, + ) + backFirebox.name = 'fireplace-firebox-back-side' + backFirebox.position.set(0, fireboxY, depth / 2 - fireboxDepth / 2) + group.add(backFirebox) + } + + // Freestanding — add a back panel to close the structure. + if (style === 'freestanding') { + const backPanel = new Mesh(new BoxGeometry(width, height, 0.05), surroundMat) + backPanel.name = 'fireplace-back-panel' + backPanel.position.set(0, height / 2, -depth / 2) + group.add(backPanel) + } + + for (const child of group.children) { + child.castShadow = true + child.receiveShadow = true + } + + return group +} diff --git a/packages/nodes/src/fireplace/index.ts b/packages/nodes/src/fireplace/index.ts new file mode 100644 index 000000000..82dd8e23b --- /dev/null +++ b/packages/nodes/src/fireplace/index.ts @@ -0,0 +1,4 @@ +export { FireplaceNode as FireplaceNodeType, FireplaceStyle, FireStyle } from '@pascal-app/core' +export { fireplaceDefinition } from './definition' +export { buildFireplaceGeometry } from './geometry' +export { FireplaceNode } from './schema' diff --git a/packages/nodes/src/fireplace/parametrics.ts b/packages/nodes/src/fireplace/parametrics.ts new file mode 100644 index 000000000..639dc2e08 --- /dev/null +++ b/packages/nodes/src/fireplace/parametrics.ts @@ -0,0 +1,55 @@ +import type { ParametricDescriptor } from '@pascal-app/core' +import type { FireplaceNode } from './schema' + +export const fireplaceParametrics: ParametricDescriptor = { + groups: [ + { + label: 'Dimensions', + fields: [ + { key: 'width', kind: 'number', unit: 'm', min: 0.6, max: 4, step: 0.05 }, + { key: 'height', kind: 'number', unit: 'm', min: 0.8, max: 4, step: 0.05 }, + { key: 'depth', kind: 'number', unit: 'm', min: 0.3, max: 1.5, step: 0.05 }, + ], + }, + { + label: 'Firebox', + fields: [ + { key: 'fireboxWidth', kind: 'number', unit: 'm', min: 0.3, max: 3, step: 0.05 }, + { key: 'fireboxHeight', kind: 'number', unit: 'm', min: 0.3, max: 3, step: 0.05 }, + { key: 'fireboxDepth', kind: 'number', unit: 'm', min: 0.2, max: 1.2, step: 0.05 }, + { key: 'fireboxSillHeight', kind: 'number', unit: 'm', min: 0, max: 1.5, step: 0.05 }, + ], + }, + { + label: 'Mantel', + fields: [ + { key: 'mantelHeight', kind: 'number', unit: 'm', min: 0.05, max: 0.5, step: 0.01 }, + { key: 'mantelOverhang', kind: 'number', unit: 'm', min: 0, max: 0.3, step: 0.01 }, + { key: 'mantelThickness', kind: 'number', unit: 'm', min: 0.03, max: 0.2, step: 0.01 }, + { key: 'mantelWidth', kind: 'number', unit: 'm', min: 0, max: 1, step: 0.01 }, + ], + }, + { + label: 'Hearth', + fields: [ + { key: 'hearthDepth', kind: 'number', unit: 'm', min: 0, max: 0.8, step: 0.05 }, + { key: 'hearthHeight', kind: 'number', unit: 'm', min: 0.02, max: 0.2, step: 0.01 }, + { key: 'hearthWidth', kind: 'number', unit: 'm', min: 0, max: 1.5, step: 0.05 }, + ], + }, + { + label: 'Fire', + fields: [ + { key: 'fire', kind: 'enum', options: ['none', 'small', 'medium', 'large', 'roaring'] }, + { key: 'fireColor', kind: 'enum', options: ['orange', 'amber', 'blue', 'white'] }, + ], + }, + { + label: 'Style', + fields: [ + { key: 'style', kind: 'enum', options: ['wall', 'freestanding', 'corner', 'double-sided'] }, + { key: 'cornerAngle', kind: 'number', unit: '°', min: 30, max: 90, step: 1 }, + ], + }, + ], +} diff --git a/packages/nodes/src/fireplace/preview.tsx b/packages/nodes/src/fireplace/preview.tsx new file mode 100644 index 000000000..b03a3edea --- /dev/null +++ b/packages/nodes/src/fireplace/preview.tsx @@ -0,0 +1,34 @@ +'use client' + +import { useViewer } from '@pascal-app/viewer' +import { useEffect, useMemo } from 'react' +import type { Material } from 'three' +import { buildFireplaceGeometry } from './geometry' +import type { FireplaceNode } from './schema' + +const FireplacePreview = ({ node }: { node: FireplaceNode }) => { + const shading = useViewer((s) => s.shading) + const built = useMemo(() => buildFireplaceGeometry(node, undefined, shading), [node, shading]) + + useEffect(() => { + const cloned: Material[] = [] + built.traverse((obj) => { + ;(obj as unknown as { raycast: () => void }).raycast = () => {} + const mesh = obj as unknown as { material?: Material } + if (mesh.material) { + const clone = mesh.material.clone() + mesh.material = clone + clone.transparent = true + clone.opacity = 0.4 + cloned.push(clone) + } + }) + return () => { + for (const m of cloned) m.dispose() + } + }, [built]) + + return +} + +export default FireplacePreview diff --git a/packages/nodes/src/fireplace/renderer.tsx b/packages/nodes/src/fireplace/renderer.tsx new file mode 100644 index 000000000..2c365b358 --- /dev/null +++ b/packages/nodes/src/fireplace/renderer.tsx @@ -0,0 +1,224 @@ +'use client' + +import { + type FireplaceNode, + useLiveNodeOverrides, + useLiveTransforms, + useRegistry, + useScene, +} from '@pascal-app/core' +import { useNodeEvents, useViewer } from '@pascal-app/viewer' +import { useFrame } from '@react-three/fiber' +import { useMemo, useRef } from 'react' +import * as THREE from 'three' +import { buildFireplaceGeometry } from './geometry' + +const FIRE_COLORS: Record = { + orange: new THREE.Color(0xff6600), + amber: new THREE.Color(0xffaa00), + blue: new THREE.Color(0x4488ff), + white: new THREE.Color(0xffeecc), +} + +const FIRE_SIZES = { + none: { count: 0, scale: 0, height: 0 }, + small: { count: 12, scale: 0.5, height: 0.25 }, + medium: { count: 20, scale: 1.0, height: 0.4 }, + large: { count: 30, scale: 1.3, height: 0.55 }, + roaring: { count: 45, scale: 1.6, height: 0.7 }, +} + +function FireParticles({ + count, + fireboxWidth, + fireboxHeight, + fireboxDepth, + sillHeight, + hearthHeight, + baseColor, +}: { + count: number + fireboxWidth: number + fireboxHeight: number + fireboxDepth: number + sillHeight: number + hearthHeight: number + baseColor: THREE.Color +}) { + const meshRef = useRef(null!) + const dummy = useMemo(() => new THREE.Object3D(), []) + const particles = useMemo(() => { + return Array.from({ length: count }, (_, i) => ({ + x: (Math.random() - 0.5) * fireboxWidth * 0.7, + z: (Math.random() - 0.5) * fireboxDepth * 0.5, + phase: Math.random() * Math.PI * 2, + speed: 0.4 + Math.random() * 0.6, + size: 0.03 + Math.random() * 0.06, + life: Math.random(), + maxLife: 0.8 + Math.random() * 0.6, + })) + }, [count, fireboxWidth, fireboxDepth]) + + const material = useMemo(() => { + const mat = new THREE.MeshBasicMaterial({ + color: baseColor, + transparent: true, + opacity: 0.7, + blending: THREE.AdditiveBlending, + depthWrite: false, + }) + return mat + }, [baseColor]) + + const geometry = useMemo(() => new THREE.SphereGeometry(1, 6, 6), []) + + useFrame((state, delta) => { + if (!meshRef.current) return + const t = state.clock.elapsedTime + for (let i = 0; i < count; i++) { + const p = particles[i]! + p.life += delta * p.speed + if (p.life > p.maxLife) { + p.life = 0 + p.x = (Math.random() - 0.5) * fireboxWidth * 0.7 + p.z = (Math.random() - 0.5) * fireboxDepth * 0.5 + } + const lifeFrac = p.life / p.maxLife + const y = lifeFrac * fireboxHeight * 0.8 + const wobble = Math.sin(t * 2 + p.phase) * 0.03 + const scale = p.size * (1 - lifeFrac * 0.5) + dummy.position.set(p.x + wobble, hearthHeight + sillHeight + y, p.z) + dummy.scale.setScalar(scale) + dummy.updateMatrix() + meshRef.current.setMatrixAt(i, dummy.matrix) + } + meshRef.current.instanceMatrix.needsUpdate = true + }) + + return ( + + ) +} + +function FireLight({ + fireboxY, + fireboxZ, + intensity, + color, +}: { + fireboxY: number + fireboxZ: number + intensity: number + color: THREE.Color +}) { + const lightRef = useRef(null!) + useFrame((state) => { + if (!lightRef.current) return + const t = state.clock.elapsedTime + const flicker = 0.8 + Math.sin(t * 7) * 0.1 + Math.sin(t * 13) * 0.05 + lightRef.current.intensity = intensity * flicker + }) + return ( + + ) +} + +const FireplaceRenderer = ({ node: storeNode }: { node: FireplaceNode }) => { + const ref = useRef(null!) + useRegistry(storeNode.id, 'fireplace', ref) + const handlers = useNodeEvents(storeNode, 'fireplace') + const liveTransform = useLiveTransforms((state) => state.get(storeNode.id)) + const shading = useViewer((s) => s.shading) + + const overrides = useLiveNodeOverrides((s) => s.get(storeNode.id)) + const node: FireplaceNode = overrides + ? ({ ...storeNode, ...overrides } as FireplaceNode) + : storeNode + + const geometry = useMemo( + () => buildFireplaceGeometry(node, undefined, shading), + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + node.width, + node.height, + node.depth, + node.style, + node.cornerAngle, + node.fireboxWidth, + node.fireboxHeight, + node.fireboxDepth, + node.fireboxSillHeight, + node.mantelHeight, + node.mantelOverhang, + node.mantelThickness, + node.mantelWidth, + node.hearthDepth, + node.hearthHeight, + node.hearthWidth, + node.surroundWidth, + node.lintelHeight, + node.material, + node.materialPreset, + node.mantelMaterial, + node.mantelMaterialPreset, + node.hearthMaterial, + node.hearthMaterialPreset, + node.fireboxMaterial, + node.fireboxMaterialPreset, + shading, + ], + ) + + useScene.getState().markDirty(node.id) + + const fireConfig = FIRE_SIZES[node.fire] + const fireColor = FIRE_COLORS[node.fireColor] ?? FIRE_COLORS.orange! + const fireboxY = node.hearthHeight + node.fireboxSillHeight + node.fireboxHeight / 2 + const fireboxZ = -node.depth / 2 + node.fireboxDepth / 2 + + return ( + + + {fireConfig.count > 0 && ( + <> + + + + )} + + ) +} + +export default FireplaceRenderer diff --git a/packages/nodes/src/fireplace/schema.ts b/packages/nodes/src/fireplace/schema.ts new file mode 100644 index 000000000..9747c822f --- /dev/null +++ b/packages/nodes/src/fireplace/schema.ts @@ -0,0 +1 @@ +export { FireplaceNode } from '@pascal-app/core' diff --git a/packages/nodes/src/fireplace/tool.tsx b/packages/nodes/src/fireplace/tool.tsx new file mode 100644 index 000000000..60bd23cf2 --- /dev/null +++ b/packages/nodes/src/fireplace/tool.tsx @@ -0,0 +1,142 @@ +'use client' + +import { + collectAlignmentAnchors, + emitter, + FireplaceNode, + type GridEvent, + useScene, +} from '@pascal-app/core' +import { + getFloorStackPreviewPosition, + isAlignmentGuideActive, + isGridSnapActive, + isMagneticSnapActive, + triggerSFX, + useAlignmentGuides, + useEditor, +} from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { useEffect, useMemo, useRef, useState } from 'react' +import type { Group } from 'three' +import { + type FloorPlacementClickTriggerEvent, + getLevelLocalSnappedPosition, + resolveAlignedFloorPlacement, + stopPlacementCommitPropagation, + subscribeFloorPlacementClicks, +} from '../shared/floor-placement' +import { fireplaceDefinition } from './definition' +import FireplacePreview from './preview' + +const FireplaceTool = () => { + const activeLevelId = useViewer((state) => state.selection.levelId) + const cursorRef = useRef(null) + const previousSnapRef = useRef<[number, number] | null>(null) + const cursorVisibleRef = useRef(false) + const [cursorVisible, setCursorVisible] = useState(false) + + const previewNode = useMemo( + () => + FireplaceNode.parse({ + ...fireplaceDefinition.defaults(), + name: 'Fireplace', + position: [0, 0, 0], + rotation: 0, + }), + [], + ) + + useEffect(() => { + if (!activeLevelId) return + previousSnapRef.current = null + cursorVisibleRef.current = false + setCursorVisible(false) + const lastCursorRef: { current: [number, number, number] | null } = { current: null } + + let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id) + + const onGridMove = (event: GridEvent) => { + if (!cursorVisibleRef.current) { + cursorVisibleRef.current = true + setCursorVisible(true) + } + + const { position, guides } = resolveAlignedFloorPlacement({ + node: previewNode, + rawX: event.localPosition[0], + rawZ: event.localPosition[2], + gridStep: useEditor.getState().gridSnapStep, + candidates: alignmentCandidates, + showAlignment: isAlignmentGuideActive(), + applyAlignmentSnap: isMagneticSnapActive(), + bypassGrid: !isGridSnapActive(), + }) + useAlignmentGuides.getState().set(guides) + + const visualPosition = getFloorStackPreviewPosition({ + node: previewNode, + position, + rotation: previewNode.rotation, + levelId: activeLevelId, + }) + cursorRef.current?.position.set(...visualPosition) + lastCursorRef.current = position + + const prev = previousSnapRef.current + if (!prev || prev[0] !== position[0] || prev[1] !== position[2]) { + triggerSFX('sfx:grid-snap') + previousSnapRef.current = [position[0], position[2]] + } + } + + const commitAtCursor = (event: FloorPlacementClickTriggerEvent) => { + const position = + lastCursorRef.current ?? + getLevelLocalSnappedPosition( + activeLevelId, + event, + useEditor.getState().gridSnapStep, + !isGridSnapActive(), + ) + const fireplace = FireplaceNode.parse({ + ...fireplaceDefinition.defaults(), + name: 'Fireplace', + position, + rotation: 0, + }) + useScene.getState().createNode(fireplace, activeLevelId) + useViewer.getState().setSelection({ selectedIds: [fireplace.id] }) + triggerSFX('sfx:item-place') + useAlignmentGuides.getState().clear() + if (useEditor.getState().getContinuation('point') === 'repeat') { + alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id) + } else { + cursorVisibleRef.current = false + setCursorVisible(false) + useEditor.getState().setTool(null) + } + + stopPlacementCommitPropagation(event) + } + + emitter.on('grid:move', onGridMove) + const unsubscribePlacementClicks = subscribeFloorPlacementClicks(commitAtCursor) + + return () => { + emitter.off('grid:move', onGridMove) + unsubscribePlacementClicks() + useAlignmentGuides.getState().clear() + } + }, [activeLevelId, previewNode]) + + if (!activeLevelId) return null + + return ( + + + + ) +} + +export default FireplaceTool diff --git a/packages/nodes/src/index.ts b/packages/nodes/src/index.ts index ee5a13b32..d0e30b77c 100644 --- a/packages/nodes/src/index.ts +++ b/packages/nodes/src/index.ts @@ -14,6 +14,7 @@ import { ductTerminalDefinition } from './duct-terminal' import { elevatorDefinition } from './elevator' import { eyebrowVentDefinition } from './eyebrow-vent' import { fenceDefinition } from './fence' +import { fireplaceDefinition } from './fireplace' import { guideDefinition } from './guide' import { gutterDefinition } from './gutter' import { hvacEquipmentDefinition } from './hvac-equipment' @@ -71,6 +72,7 @@ export const builtinPlugin: Plugin = { doorDefinition as unknown as AnyNodeDefinition, windowDefinition as unknown as AnyNodeDefinition, itemDefinition as unknown as AnyNodeDefinition, + fireplaceDefinition as unknown as AnyNodeDefinition, // Stage A — wrap-exports the legacy renderer + system. Legacy // panels / move tools / floorplan branches still serve these. columnDefinition as unknown as AnyNodeDefinition,