Skip to content

feat: add Fireplace node with animated procedural fire - #488

Open
toolucid wants to merge 1 commit into
pascalorg:mainfrom
toolucid:feat/fireplace-node
Open

feat: add Fireplace node with animated procedural fire#488
toolucid wants to merge 1 commit into
pascalorg:mainfrom
toolucid:feat/fireplace-node

Conversation

@toolucid

@toolucid toolucid commented Jul 11, 2026

Copy link
Copy Markdown

What does this PR do?

Adds a new architectural Fireplace node kind to the editor — the first node with an animated procedural effect (instanced fire particles + flickering point light via useFrame).

  • 4 fireplace styles: wall, freestanding, corner, double-sided
  • Parametric mantel, hearth, surround, and firebox with inspector controls
  • Animated instanced fire particle system with additive blending + a flickering point light
  • 5 fire intensity levels (noneroaring), 4 fire colors (orange, amber, blue, white)
  • Placement tool with grid snap, alignment guides, SFX, and translucent ghost preview
  • Resize / rotate / move handles in the 3D viewport

Registered through the builtin plugin via the same NodeDefinition contract as all other node kinds. Follows the three-checkbox model from wiki/architecture/node-definitions.md: geometry (pure builder) + renderer (custom React with useFrame) + tool (lazy placement component).

How to test

  1. git clone https://github.com/toolucid/editor.git && cd editor && bun install && bun dev
  2. Open http://localhost:3002 in a browser
  3. In the left sidebar palette, find the Fireplace under the Furnish section
  4. Click it, then click on the floor to place a fireplace
  5. Verify: mantel, hearth, surround, and firebox render correctly; fire particles animate upward with a flickering light
  6. Select the fireplace and use the inspector to change fire intensity (noneroaring), fire color, and fireplace style
  7. Verify resize / rotate / move handles work in the 3D viewport
  8. Run bun check-types and bun check — both pass with 0 errors

Screenshots / screen recording

Will add a screen recording before review.

Checklist

  • I've tested this locally with bun dev
  • My code follows the existing code style (run bun check to verify)
  • I've updated relevant documentation (if applicable)
  • This PR targets the main branch

Note

Medium Risk
Adds a new discriminated union member and event surface area, plus per-instance useFrame animation and extra lights that could affect viewer performance with many fireplaces; otherwise mirrors existing furnish nodes with no auth or persistence changes.

Overview
Introduces a new fireplace furnish node end-to-end: Zod schema in core, FireplaceEvent on the editor bus, and registration in the builtin plugin.

The node supports parametric dimensions (mantel, hearth, surround, firebox), four styles (wall, freestanding, corner, double-sided), and inspector fire settings (intensity and color). Implementation follows the standard NodeDefinition split: box-based geometry builder, a custom renderer with instanced particles and a flickering point light driven by useFrame, floor placement tool (grid snap, alignment guides, ghost preview), and viewport resize/rotate/move handles.

Also updates apps/ifc-converter/next-env.d.ts to import Next route types from ./.next/types/routes.d.ts instead of the dev path.

Reviewed by Cursor Bugbot for commit a8d3b37. Bugbot is set up for automated code reviews on this repo. Configure here.

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).

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 11 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit a8d3b37. Configure here.

<group
ref={ref}
position={node.position}
rotation={[0, (node.rotation * Math.PI) / 180, 0]}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rotation treated as degrees

High Severity

The FireplaceRenderer expects node.rotation to be in degrees and converts it to radians, but node.rotation is stored in radians elsewhere. This unit mismatch causes the fireplace to rotate by a much smaller amount than intended when adjusted via handles or other controls.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a8d3b37. Configure here.

visible={node.visible}
{...handlers}
{...liveTransform}
>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Live transform breaks rotation prop

High Severity

Spreading liveTransform onto the <group> passes a scalar rotation value. However, the <group>'s rotation prop expects an Euler tuple, causing incorrect rotation behavior and visual jumps during interactive drags.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a8d3b37. Configure here.

handles: fireplaceHandles,
parametrics: fireplaceParametrics,
geometry: buildFireplaceGeometry,
renderer: { kind: 'parametric', module: () => import('./renderer') },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate fireplace geometry mounted

High Severity

The fireplace geometry is rendered twice. It's defined in NodeDefinition.geometry for GeometrySystem to handle, but the custom FireplaceRenderer also explicitly renders the same geometry. This results in doubled visuals and can cause transformation issues, like dropped corner yaw, especially since markDirty is called on every render.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a8d3b37. Configure here.

mantel.name = 'fireplace-mantel'
const mantelY = height - mantelThickness / 2
mantel.position.set(0, mantelY, mantelOverhang / 2)
group.add(mantel)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mantel height control ignored

Medium Severity

The mantelHeight property is exposed and triggers rebuilds, but its value only determines if a mantel is rendered. The mantel's actual dimensions and vertical placement are controlled by mantelThickness and the overall height, so adjusting mantelHeight has no visual impact.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a8d3b37. Configure here.

intensity={fireConfig.scale * 2}
color={fireColor}
/>
</>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Double-sided fire only on front

Medium Severity

For double-sided fireplaces, the geometry includes a rear firebox opening, but fire particles and light are only rendered at the front. This leaves the back opening dark and static, preventing the fireplace from appearing genuinely two-sided.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a8d3b37. Configure here.

// Corner style — rotate the whole structure by cornerAngle.
if (style === 'corner') {
group.rotation.y = (cornerAngle * Math.PI) / 180
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Corner style desyncs handles

Medium Severity

For style: 'corner', yaw is applied on the builder Group via cornerAngle, while resize/rotate/move handles are placed in the outer node frame. The visible mesh rotates under the handles, so gizmos no longer line up with the fireplace body. GeometrySystem also drops that group rotation when reparenting children.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a8d3b37. Configure here.

node.fireboxMaterialPreset,
shading,
],
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Geometry resources never disposed

Low Severity

buildFireplaceGeometry results are swapped via useMemo with no dispose of child BoxGeometry/materials, and FireParticles creates a SphereGeometry plus MeshBasicMaterial without teardown. Parametric edits and fire toggles leak GPU resources over a session.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a8d3b37. Configure here.

/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unrelated Next env change

Low Severity

This commit changes apps/ifc-converter/next-env.d.ts (an auto-generated Next.js types stub marked “should not be edited”) with no connection to the Fireplace feature. It looks like an accidental local regen included in the PR.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a8d3b37. Configure here.

}

emitter.on('grid:move', onGridMove)
const unsubscribePlacementClicks = subscribeFloorPlacementClicks(commitAtCursor)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Placement ignores fireplace clicks

Medium Severity

subscribeFloorPlacementClicks only listens for clicks on kinds listed in FLOOR_PLACEMENT_CLICK_TRIGGER_KINDS, and that list was never updated for fireplace. Clicks on an existing fireplace do not commit floor placement for the fireplace tool or other floor tools that share this helper.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a8d3b37. Configure here.

selectable: { hitVolume: 'bbox' },
duplicable: true,
deletable: true,
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing slab elevation support

Medium Severity

Unlike shelf, column, spawn, and item, the fireplace definition never declares capabilities.floorPlaced. FloorElevationSystem therefore never lifts it onto overlapping slabs, so a fireplace on a raised slab stays at level Y and sinks through the slab. The placement tool still calls getFloorStackPreviewPosition, which cannot elevate correctly without a footprint.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a8d3b37. Configure here.

@Aymericr

Copy link
Copy Markdown
Contributor

This is a distinct and useful node proposal, so I’m keeping it open, but it needs a current-main revision before architectural review:

  • rebase onto the current builtin-plugin/node-definition contracts and remove the unrelated generated next-env.d.ts change
  • resolve the 11 inline Bugbot findings
  • add schema, geometry, handle, and placement-tool tests
  • define bake/export behavior for the procedural particles and light
  • include the promised recording and a many-fireplace performance check; per-instance useFrame callbacks and point lights are the main runtime risk

A focused follow-up commit satisfying those points would make this reviewable as a real feature rather than a prototype snapshot.

@Aymericr Aymericr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this — a lot of it is in the right shape. Specifically good: the node is registered in both required places (AnyNode union in packages/core/src/schema/types.ts and builtinPlugin.nodes in packages/nodes/src/index.ts), buildFireplaceGeometry is a genuinely pure (node) => Group builder, the tool reuses the shared helpers in packages/nodes/src/shared/floor-placement.ts instead of re-rolling raycasts, and the HandleDescriptor set looks right. bun run check does exit 0 on this branch.

That said I can't merge it as-is — there are runtime breakages, and they mostly trace back to one architectural decision.

The root issue: this definition declares both geometry and renderer. packages/nodes/src/fireplace/definition.ts:154-155 is the only definition in the repo (of 46) doing that. <NodeRenderer> returns early when def.renderer is present, so <ParametricNodeRenderer> never runs — but your renderer also calls useRegistry (so <GeometrySystem> reparents the built children onto the group) and mounts <primitive object={geometry} /> from its own useMemo build at renderer.tsx:200. That's two copies of every mesh. wiki/architecture/node-definitions.md:189 treats geometry and renderer as alternatives. Simplest fix: drop renderer, delete fireplace/renderer.tsx, and let the shared parametric path own the mount. Almost everything below disappears if you do.

Blocking, in order:

  1. {...liveTransform} NaNs the transform on dragrenderer.tsx:198. LiveTransform.rotation is a number, and R3F's applyProps on a number without setScalar does target.set(value). THREE.Euler has no setScalar, so rotation.set(0.75) leaves y/z undefined and updateMatrix() yields an all-NaN matrix — I ran this to confirm. The fireplace vanishes mid-drag. parametric-node-renderer.tsx:67-82 normalizes rotation to a tuple exactly to avoid this.
  2. Degrees/radians mismatchrenderer.tsx:195 does (node.rotation * Math.PI) / 180, but packages/core/src/schema/nodes/fireplace.ts:29 rotation is radians like every other node. Should be rotation={[0, node.rotation, 0]}. (cornerAngle is degrees and does need the conversion — that one's fine.)
  3. markDirty in the render bodyrenderer.tsx:184 writes to the store during render, re-triggering a rebuild each commit. Move it into a useLayoutEffect keyed on the geometry params. Related: #556 is fixing the same class of thing for walls.
  4. Corner style's yaw is silently droppedgeometry.ts:176 sets group.rotation.y, but <GeometrySystem> reparents only built.children and never applies the built group's transform. Wrap the 8 meshes in an inner rotated Group (returned as the root's single child), or bake the rotation into the children.
  5. mantelHeight is a dead controlgeometry.ts:164 only uses it as a > 0 gate; it never affects the mantel's size or Y. I diffed 0.05 vs 0.50 and got byte-identical BoxGeometry params and the same position, yet it's exposed in parametrics.ts:26. Either wire it to the thickness or remove the control.
  6. capabilities.floorPlaced is missingdefinition.ts:141-150. Without it getFloorPlacedElevation early-returns 0 (packages/core/src/hooks/spatial-grid/floor-placed-elevation.ts:67-75), so the getFloorStackPreviewPosition call in tool.tsx:77-82 is inert and a fireplace on a raised slab sinks through it. Also add 'fireplace' to FLOOR_PLACEMENT_CLICK_TRIGGER_KINDS (shared/floor-placement.ts:16-28) so the placement click actually fires.

Should-fix:

  • No disposal anywhere — the useMemo'd group, the ember SphereGeometry/MeshBasicMaterial, and the built geometry in preview.tsx all leak. cupola/renderer.tsx has the pattern (useEffect(() => () => geometry.dispose(), [geometry])).
  • Raw per-node <pointLight> + two useFrame callbacks (renderer.tsx:127, 75, 120) bypass the pooled light system (packages/viewer/src/systems/item-light/item-light-system.tsx, POOL_SIZE = 12, with camera scoring and hysteresis). Register a LightEffect via useItemLightPool — see packages/nodes/src/item/renderer.tsx:805. Twenty fireplaces currently means 20 lights and 40 frame callbacks.
  • No tests. A fireplace/geometry.test.ts covering child count per style, mantelHeight affecting dimensions, corner yaw landing on children, and the lintel staying inside node.height at the schema minimum (0.8 currently puts the lintel top at 1.17 m) would lock all of the above in.
  • CI has never run on a8d3b37 — please push so the required checks execute.

Nits: renderer.tsx:152 uses // eslint-disable-next-line react-hooks/exhaustive-deps, which is inert here — this repo uses // biome-ignore lint/correctness/useExhaustiveDependencies:. The apps/ifc-converter/next-env.d.ts change is unrelated to the feature and reverts a generated path main still carries; please drop it. The four near-identical material resolvers in geometry.ts could collapse into one resolveMaterial(node, role), which would also give the exported FireplaceMaterialRole a use. And there's no floorplan builder, no mcp block (this would be the only definition of 46 without one), and no bake policy — worth deciding deliberately whether the fireplace should show in 2D plans, be MCP-addressable, and survive GLB export.

Two things that are not on you: the packages/core/src/events/bus.ts conflict and the removed packages/plugin-trees are just base drift (82 commits since merge base 7d6879c4) — a rebase clears both. And the local bun-test failures reproduce identically on main, so they're environmental.

One process question before you invest in the fixes: I couldn't find an issue requesting a fireplace node. Could you say a bit about the use case? If it's wanted, the geometry builder here is a good starting point and the fix list gets much shorter once the custom renderer is dropped.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants