A standalone Lovelace custom card for placing Home Assistant entities on an interactive 3D house model, with real point-light illumination instead of zone overlays. Load a .glb, switch to Edit Mode, place markers on the model, then configure real lights, animated objects, and covers directly in the card's own editor.
This is a fork of Hollako/Home-Assistant-3D-Floorplan that has grown into a fully independent card: it no longer loads or extends the original at runtime. The custom element tags are custom:home-assistant-3d-floorplan-ext (card) and home-assistant-3d-floorplan-ext-editor (config editor) — both installable side by side with the original card, since neither the tag names nor the config schema collide.
None of this would exist without Hollako's original Home-Assistant-3D-Floorplan card. Getting real-time entity markers, physically-based lighting, and a full 3D model editor working smoothly inside a Lovelace custom card — in plain JavaScript, no build step — is genuinely excellent, well-crafted engineering. The marker system, coordinate handling, camera controls, and editor UI this fork builds on are all their work, and all of it is a pleasure to read and extend. Sincere thanks for building it and sharing it.
The original card's room lighting is driven by "Areas" (brightness_zones): flat polygons on the floor that light up their footprint. That works well for a simple floor glow, but it has two structural limits that this fork exists to remove:
- Floor level is always 0. The overlay-to-model point conversion zeroes out the vertical axis unconditionally, so a zone can't sit at a real floor height — annoying on any model whose ground floor isn't at world-Y 0.
- One zone lights everything inside its polygon. A garden that wraps around a house needs one giant zone, but there's no way to stop that zone's light from leaking through walls into every room inside its own footprint.
Rather than patch around those limits, this fork replaces zone-based lighting entirely with real, individually placed point_lights — each one an actual THREE.PointLight / THREE.SpotLight / LED-strip approximation, positioned in true 3D space, with correct wall-shadowing (opt-in per light) and no polygon-vs-room ambiguity.
| Original | This fork | |
|---|---|---|
| Room/area lighting | brightness_zones polygons, floor level fixed at 0 |
Removed entirely — see point_lights below |
| Point/spot/LED lights | Not available | point_lights: point, spot, and linear (LED-strip) kinds, each independently shadow-capable |
| Shadow rendering | Not supported | Dynamic shadow budget (max 4 concurrent shadow-casters, only counting lights that are currently on) |
| Marker tap/hold actions | Configured per-marker in the Edit Mode popup | Configured centrally in the point_lights editor section; the marker popup is positioning-only |
| Marker name label | Global on/off only | Global default plus a per-marker show_label override |
| Value markers (temperature, etc.) | temperature/humidity device classes |
Also wind_speed, pressure, atmospheric_pressure, illuminance |
| Cover/blind position tracking | Not available | slide_animations (linear position → mesh offset) and lamella_groups (tilt position → one-of-three mesh swap) |
| Stepped visibility | Not available | segment_visibility (sensor state → mesh show/hide, with hidden_state or show_state logic) |
| Publishing marker/view changes | Manual "Copy YAML" only | One-click Publish to Dashboard button that writes straight into the live dashboard config, with a clipboard fallback if it can't |
| Live robot vacuum position | Not available | vacuum_trackers: a live-tracked marker (or a real 3D object) that follows a robot vacuum's actual position, calibrated against your model's coordinates |
| Config editor | Original editor, wrapped | Fully forked and merged into one editor with collapsible sections |
| Versioning | Upstream version number | Own semantic version (currently 1.3.0), shown in the editor and in exported settings files |
Everything the original card does that isn't listed above — marker placement, the coordinate system, color thresholds, animations, interactive_objects, camera views, offline-device detection — works the same way here, because most of that code is carried over unchanged.
- HACS → the three-dot menu → Custom repositories.
- Add this repository's URL, category Dashboard.
- Install Home Assistant 3D Floorplan Ext, then add the resource (HACS does this automatically).
Copy home-assistant-3d-floorplan-ext.js into <config>/www/, then add it as a Lovelace resource:
url: /local/home-assistant-3d-floorplan-ext.js
type: moduletype: custom:home-assistant-3d-floorplan-ext
title: My House
model: /local/floorplans/home.glb
view_mode: "3d"
markers: []
point_lights: []All of the original card's top-level options (marker_size, show_labels, show_entity_state, coordinate_map, ambient_darkness, offline_states, model_performance_profile, and so on) work exactly as documented in the original README — they aren't repeated in full here.
Marker placement, coordinates, color thresholds, and live-state coloring are unchanged from the original. Two additions:
markers:
- entity: sensor.garden_wind_speed
name: Wind speed
marker_display: value # auto | icon | value — auto now also covers
# wind_speed / pressure / atmospheric_pressure / illuminance
show_label: hide # auto | show | hide — overrides the global
# "Names" toggle for just this marker (handy
# when two markers sit close together)Tap/hold actions are not set on the marker anymore — see Point Lights below; the marker's Edit Mode popup only has Icon, Marker display, Marker name, Light intensity, and X/Y/Z position.
A real Three.js light, independent of the marker/zone system — no marker required, though a light can follow one automatically.
point_lights:
- entity: light.kitchen_ceiling
light_kind: point # point | spot | linear, default point
position: {} # omit or leave a field blank to follow the
# marker with the same entity on that axis
color: "#ffd9aa"
intensity: 1.5 # brightness when on; for light.* entities
# this is additionally scaled by the actual
# brightness attribute (0-255)
distance: 300
decay: 2
on_state: "on"
cast_shadows: false # see shadow budget below
tap_action: toggle # toggle | more-info | navigate | none
hold_action: more-info
navigation_path: "" # used when tap/hold action is "navigate"light_kind: spot adds a directed cone, aimed down by default:
angle: 0.5 # cone half-angle, radians
penumbra: 0.3 # cone edge softness, 0-1
tilt_x: 0 # degrees, sideways tilt
tilt_y: 0light_kind: linear approximates an LED strip as several point-light samples along a line through the position:
strip_width: 200 # model units
strip_angle: 0 # degrees, in the X/Z plane
sample_count: 5 # 2-12cast_shadows: true makes a light shadow-casting (walls block it) at the cost of a real-time cube shadow map. Too many simultaneous shadow-casting lights overflow the GPU's shader variable limit and make the entire model disappear — not just the offending light — so this fork enforces a hard cap of 4 concurrent shadow-casters, counted only among lights that are currently on (a linear light's samples count individually). The budget is re-allocated every frame: turn one shadow-casting light off and another one configured for shadows will pick up the freed slot automatically. A 5th light beyond the budget still lights normally, just without its own shadow.
Unchanged from the original — animations (rotate/oscillate/bob tied to an entity state) and interactive_objects (click a mesh to toggle/more-info/navigate, optional per-state color/opacity) work exactly as before.
For covers whose position (not just open/closed) should move a mesh — rollershutters, blinds sliding on a track:
slide_animations:
- object_name: rollo_mitte
entity: cover.living_room_blind
attribute: current_position # optional, default current_position
axis: y # x | y | z, optional default y
from_value: 0
to_value: 100
distance: 70.0 # model units moved from open to closed
invert: false
hide_at_top: true # hide the mesh once fully retractedFor venetian-blind style covers where tilt position selects one of three fixed mesh states (closed/half/open) rather than a continuous slide:
lamella_groups:
- entity: cover.east_blind
attribute: current_tilt_position # optional, default current_tilt_position
objects:
zu: blind_east_closed
halb: blind_east_half
auf: blind_east_open
thresholds: # optional, default 33 / 66
zu_max: 33
halb_max: 66Simple 1:1 sensor-to-mesh visibility, e.g. for a pergola roof that reveals itself in steps:
segment_visibility:
- object_name: entity_pergola_segment_01
entity: binary_sensor.pergola_under_10_percent
hidden_state: "on" # optional, default "on" — hidden while the sensor
# has this state; ANY other state (including
# "unavailable") shows the object
show_state: "" # optional — if set, takes priority over
# hidden_state: the object is visible ONLY
# while the entity is exactly this state,
# everything else (including "unavailable")
# hides itobject_name matches both the exact mesh name and any <object_name>_m<N> material-split siblings that SweetHome3D/Blender exports produce for multi-material objects — all parts show/hide together.
Shows a robot vacuum's live position inside the 3D model — either as a small pulsing marker, or as a real 3D object that moves across the floor. This isn't specific to any one vacuum brand: it works with any integration that exposes the vacuum's position as {x, y} (or {x, y, a} with a heading angle) in the vacuum's own fixed local coordinate system — for example the Dreame Vacuum integration's camera.<vacuum>_map entity, whose vacuum_position/charger_position attributes this was built and tested against, as well as most Xiaomi/Roborock/Valetudo-based integrations that expose similar map data.
vacuum_trackers:
- entity: vacuum.my_robot # used only to read the current state, for hiding
map_entity: camera.my_robot_map # entity carrying the live position attribute
position_attribute: vacuum_position # optional, default "vacuum_position"
hidden_states: docked # comma-separated (editor) or YAML list — states
# in which the marker/object is hidden entirely.
# Leave empty to never hide it.
height: 78 # model Y (floor height) the marker/object sits at
icon: mdi:robot-vacuum # icon-marker mode only (see below)
calibration:
a: 0.0
b: 0.0
tx: 0.0
ty: 0.0
invert_y: false # see "Getting the calibration right" below- Icon marker (default) — nothing further to configure. A small round marker with the configured
iconappears and moves across the floor. - Real 3D object — add
object_name(andorigin, see below) to move an actual mesh from your model instead of a flat icon:
object_name: entity_vacuum_robot # a mesh in your model, named/tagged the
# same way as segment_visibility objects
origin: { x: -80, y: 75, z: 364 } # the exact position where you PLACED
# that object in your 3D editor (see below)
rotate_with_heading: true # optional: rotate the object to face
# its direction of travel
heading_offset: 0 # optional, degrees — corrects for
# whichever direction "forward" faces
# on the modeled objectIf object_name is set but the mesh doesn't exist in the currently loaded model (e.g. you haven't re-exported it yet), this falls back to the icon marker automatically — nothing breaks.
Why origin is required for the 3D-object mode: SweetHome3D (and most other 3D editors) bake an object's absolute position directly into its exported vertex data — the loaded mesh's own position starts at (0, 0, 0) regardless of where it visually sits in the model. To move it to a live, calculated position, this fork needs to know where it started (i.e. exactly where you placed it while modeling), so it can apply the difference as an offset. The simplest way to get an exact, reliable origin: model the vacuum sitting exactly on its charging dock, then use the dock's known coordinates (see calibration below) as origin — since the vacuum returns to that exact spot every time it docks, it's a self-verifying reference point.
calibration is a 2D similarity transform (rotation + uniform scale + translation) that converts the vacuum's own raw {x, y} coordinates into your model's {x, z} coordinates (floor plane; model y stays fixed at height):
modelX = a·vx − b·vy + tx
modelZ = b·vx + a·vy + ty
To find a, b, tx, ty, you need two known, exact correspondences between a raw vacuum coordinate and the matching 3D-model coordinate:
- The charging dock. Whenever the vacuum is docked, its position attribute reads exactly the dock's fixed coordinates — a free, always-precise reference point. Note that raw
(vx, vy)down from your vacuum's map data. - A second point, as far from the dock as practical. Pause the vacuum somewhere else in the house, note its current raw
(vx, vy), and in Edit Mode place a temporary marker at that exact physical spot to read off the matching model(x, z). The farther apart the two points are, the more accurate the resulting rotation/scale — two points close together amplify any small measurement error into a large rotational error far away.
With two point pairs (v1 → p1) and (v2 → p2) (as complex numbers v = vx + i·vy, p = x + i·z):
s = (p1 − p2) / (v1 − v2) # s = a + i·b
t = p1 − s·v1 # t = tx + i·ty
Sanity-check the scale (|s| = sqrt(a² + b²)) against your model's real-world scale — e.g. if your model is authored in centimeters, |s| should be close to 100 (model units per real meter). A value wildly off (like 2× or 0.5×) usually means one of the two reference points wasn't actually exact.
invert_y: the formula above is a pure rotation — it can't represent a mirrored coordinate system. If, after calibrating, one axis comes out backwards (e.g. left/right is correct but front/back is reversed, or vice versa), set invert_y: true and recompute a/b/tx/ty using (vx, −vy) in place of (vx, vy) for both reference points — a straight rotation fit through the mirrored inputs. This isn't a rare edge case: whether the vacuum's own map coordinate system happens to be mirrored relative to your 3D model depends on details of how the vacuum built its map, and can't be predicted in advance — treat it as a normal part of calibrating, not a sign something's broken.
Two smaller pitfalls this fork's implementation already handles for you, in case you're porting the idea elsewhere:
- The live position keeps updating even while the marker/object is hidden (e.g. docked), so when it reappears it's already at the correct spot instead of visibly animating in from a stale position.
hidden_statesshould generally include only the truly-parked state (e.g.docked) — a state like "returning to dock" still means the vacuum is visibly moving across the floor, so hiding it there makes it disappear well before it actually arrives.
Marker positions and the "Save Home" startup camera view are saved to the browser's localStorage when you place/adjust them in Edit Mode — convenient for iterating, but invisible on any other device until they're written into the actual dashboard config. Edit Mode's sidebar has a Publish to Dashboard button that does this itself, using the browser's already-authenticated Home Assistant connection:
- Fetches the current dashboard config.
- Finds the one card on it matching this card's type and
modelpath. - Overwrites only
markers,default_view,ambient_darkness,light_presets, and a few performance settings on that card —point_lights,animations,interactive_objects, and everything else are left untouched. - Asks for confirmation, then saves.
If it can't find exactly one matching card (or has no live connection), it copies the same data to the clipboard instead, so nothing is lost — paste it into Home Assistant's own "Edit in YAML" card editor.
All unchanged from the original — see its README for the coordinate convention, compass/Save Home behavior, and offline-marker alert.
ISC — see LICENSE. Same license as the upstream project, with an added copyright line for this fork's own contributions.
Built on top of Hollako/Home-Assistant-3D-Floorplan. All credit for the original card, marker system, and 3D rendering foundation goes to its author.