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,