Skip to content

Commit 70f3fae

Browse files
committed
Automorph: morph FROM the fixture's real manual footprint, not a generic hex
The morph's starting shape now scales and rotates by the same markerScale transform the real glyph draws with (automorphIconRing), so a 240cm strip at 30 degrees grows its aura from its own long, angled outline instead of snapping to a small default hex the instant Automorph turns on. Identity no-op for fixtures with no recorded manual size.
1 parent 90b794a commit 70f3fae

2 files changed

Lines changed: 110 additions & 3 deletions

File tree

custom_components/padspan_ha/www/padspan-ha/views/iso_lights.js

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,29 @@ export function iconRingLocal(shape, r){
268268
return pts;
269269
}
270270

271+
// The morph's STARTING point should be the fixture's REAL manually-set
272+
// footprint, not the generic default-radius glyph iconRingLocal alone draws
273+
// (Garry, 2026-09-07: "the existing manual shapes are still meant to be a
274+
// guide for the overall look, don't throw that info away"). Reuses
275+
// markerScale verbatim — the SAME function the real (non-automorph) glyph's
276+
// own `translate(hx,hy) rotate(rot) scale(sx,sy)` transform already scales
277+
// and rotates with — so a 240cm strip at 30° starts the morph as its own
278+
// real long, angled shape instead of snapping to a small hex the instant
279+
// Automorph turns on, and the two stay visually consistent with each other.
280+
// With no manual size recorded, markerScale returns identity {sx:1,sy:1},
281+
// so this is a byte-for-byte no-op for every fixture that has none —
282+
// exactly today's iconRingLocal(shape, hexR) output, unchanged.
283+
export function automorphIconRing(shape, wCm, hCm, rotDeg, scale, hexR){
284+
const local=iconRingLocal(shape, hexR);
285+
const {sx,sy}=markerScale(wCm, hCm, scale, hexR);
286+
const rot=(Number(rotDeg)||0)*Math.PI/180;
287+
const cos=Math.cos(rot), sin=Math.sin(rot);
288+
return local.map(([x,y])=>{
289+
const sxp=x*sx, syp=y*sy;
290+
return [sxp*cos-syp*sin, sxp*sin+syp*cos];
291+
});
292+
}
293+
271294
// The morph itself. `iconLocal` is centred on (0,0) (iconRingLocal's own
272295
// output); `iconCx,iconCy` places it at the fixture's real drawn position.
273296
// `roomRingAbs` is the room's own outline in the SAME space (whatever space
@@ -2111,13 +2134,15 @@ export function buildIsoSVG(model, byRoom, hiddenEids, focusZ, floorGap, horizGa
21112134
// breaking. This is the smaller, reviewable step: the real morph maths
21122135
// (automorphRing) proven and shipped, with "replace the icon's own
21132136
// outline" left as a deliberate follow-up once this reads well live.
2114-
const automorphAuraSvg=(l,hx,hy,room,z,cellPtsM)=>{
2137+
const automorphAuraSvg=(l,hx,hy,room,z,cellPtsM,entry)=>{
21152138
if(!(AUTOMORPH_PCT>0) || !room || room.pts.length<3) return "";
21162139
const targetPts=(cellPtsM && cellPtsM.length>=3) ? cellPtsM : room.pts;
21172140
const marginM=Math.max(0, Math.min(defaultPerimeterMarginM(frame), roomHalfMinDim(targetPts)*0.85));
21182141
const roomPx=offsetPolygonInward(targetPts, marginM).map(p=>iso(p[0],p[1],z));
2142+
const iconLocal=automorphIconRing(l.shape, entry&&entry.width_cm, entry&&entry.height_cm,
2143+
entry&&entry.rotation, frame.scale, HEX_R);
21192144
const ring=applyHardness(
2120-
automorphRing(iconRingLocal(l.shape, HEX_R), hx, hy, roomPx, AUTOMORPH_PCT/100),
2145+
automorphRing(iconLocal, hx, hy, roomPx, AUTOMORPH_PCT/100),
21212146
AUTOMORPH_HARDNESS);
21222147
const d=ringPathD(ring, AUTOMORPH_HARDNESS);
21232148
const on=l.isMotion ? motionActive(l) : (l.isLock ? l.state==="locked" : l.state==="on");
@@ -2600,7 +2625,7 @@ export function buildIsoSVG(model, byRoom, hiddenEids, focusZ, floorGap, horizGa
26002625
else if(AUTOMORPH_PCT>0){
26012626
const cellsInRoom=room && roomFixtureCells.get(room);
26022627
const cellPtsM=cellsInRoom && cellsInRoom.get(pl.eid);
2603-
s+=automorphAuraSvg(l, hx, hy, room, z, cellPtsM);
2628+
s+=automorphAuraSvg(l, hx, hy, room, z, cellPtsM, pl.lp);
26042629
}
26052630
// Whether an aura ACTUALLY painted for this fixture — the same room
26062631
// truthiness automorphAuraSvg itself bails on. This, not the bare

tests/test_lights_renderer.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2449,3 +2449,85 @@ def test_automorph_two_fixtures_sharing_a_room_render_different_auras(tmp_path):
24492449
f"the two fixtures' aura outlines must differ — identical outlines mean the partition "
24502450
f"was not applied and both fell back to the same full-room shape: {out}"
24512451
)
2452+
2453+
2454+
# ── Automorph icon endpoint: the fixture's REAL manual footprint ────────────
2455+
# (Garry, 2026-09-07: "the existing manual shapes are still meant to be a
2456+
# guide for the overall look, don't throw that info away.") The morph's
2457+
# starting shape is automorphIconRing — iconRingLocal scaled and rotated by
2458+
# the SAME markerScale transform the real glyph already draws with — so a
2459+
# strip's aura grows from its own long, angled footprint, not a generic hex.
2460+
2461+
def test_automorph_icon_ring_without_manual_size_is_exactly_the_plain_icon(tmp_path):
2462+
"""No recorded width/height and no rotation must be a byte-for-byte
2463+
no-op — the same identity contract every other Automorph control's rest
2464+
position holds, so no existing fixture's aura moves a pixel."""
2465+
out = _run_js(tmp_path, (
2466+
"import { automorphIconRing, iconRingLocal } from './iso_lights.mjs';\n"
2467+
"const a=automorphIconRing('circle', 0, 0, 0, 30, 10);\n"
2468+
"const b=iconRingLocal('circle', 10);\n"
2469+
"console.log(JSON.stringify({equal: JSON.stringify(a)===JSON.stringify(b)}));\n"
2470+
))
2471+
assert out["equal"], "no manual size + no rotation must return iconRingLocal's own points untouched"
2472+
2473+
2474+
def test_automorph_icon_ring_scales_per_axis_and_rotates_like_the_real_glyph(tmp_path):
2475+
"""A wide manual footprint must stretch the ring along x (and leave y at
2476+
its soft-floored height); rotating the same fixture 90° must carry that
2477+
long axis to y — the same scale-then-rotate order the real glyph's own
2478+
`rotate(rot) scale(sx,sy)` transform applies to each point."""
2479+
out = _run_js(tmp_path, (
2480+
"import { automorphIconRing } from './iso_lights.mjs';\n"
2481+
"const ext=(pts)=>{let x=0,y=0;for(const p of pts){x=Math.max(x,Math.abs(p[0]));y=Math.max(y,Math.abs(p[1]));}return {x,y};};\n"
2482+
"const plain=ext(automorphIconRing('square', 0, 0, 0, 30, 10));\n"
2483+
"const wide=ext(automorphIconRing('square', 400, 20, 0, 30, 10));\n"
2484+
"const wideTurned=ext(automorphIconRing('square', 400, 20, 90, 30, 10));\n"
2485+
"console.log(JSON.stringify({plain, wide, wideTurned}));\n"
2486+
))
2487+
plain, wide, turned = out["plain"], out["wide"], out["wideTurned"]
2488+
assert wide["x"] > plain["x"] * 2, f"a 4 m width must visibly stretch the ring along x: {out}"
2489+
assert wide["x"] > wide["y"] * 2, f"the stretched ring must actually be wide, not scaled uniformly: {out}"
2490+
assert abs(turned["x"] - wide["y"]) < 1e-6 and abs(turned["y"] - wide["x"]) < 1e-6, (
2491+
f"rotating 90° must swap the long axis exactly: {out}"
2492+
)
2493+
2494+
2495+
def test_automorph_aura_grows_from_the_real_manual_footprint_not_a_generic_hex(tmp_path):
2496+
"""End-to-end: at a low room%, a fixture with a real 240cm-wide manual
2497+
footprint must render a much WIDER aura outline than the identical
2498+
fixture with no manual size — before this, both started from the same
2499+
small default-radius icon and the manual shape information never reached
2500+
the morph at all."""
2501+
NOW = 1_000_000_000_000
2502+
2503+
def render(extra_lp):
2504+
model = {
2505+
"room_geometry_m": {"Office": {"type": "poly", "floor_id": "main", "points_m": [[0, 0], [8, 0], [8, 8], [0, 8]]}},
2506+
"light_positions_m": {"light.strip": {"x_m": 4, "y_m": 4, "floor_id": "main", **extra_lp}},
2507+
}
2508+
lbe = {"light.strip": {"entity_id": "light.strip", "state": "on", "code": "W01", "shape": "bar", "isMotion": False, "last_changed": None}}
2509+
floors = [{"id": "main", "name": "Main", "level": 0}]
2510+
out = _run_js(tmp_path, (
2511+
"import * as M from './iso_lights.mjs';\n"
2512+
f"const MODEL={json.dumps(model)};\n"
2513+
f"const LBE={json.dumps(lbe)};\n"
2514+
f"const FLOORS={json.dumps(floors)};\n"
2515+
f"const svg=M.buildIsoSVG(MODEL,{{}},new Set(),null,150,0,LBE,false,FLOORS,"
2516+
f"{{nowMs:{NOW}, automorph:true, automorphRoomPct:1, automorphHardness:0, automorphStyle:'blueprint'}});\n"
2517+
"const m = svg.match(/<path d=\"([^\"]+)\"[^>]*stroke-dasharray=\"4,3\"/);\n"
2518+
"if(!m){ console.log(JSON.stringify({w: null})); }\n"
2519+
"else {\n"
2520+
" const nums=[...m[1].matchAll(/(-?[\\d.]+),(-?[\\d.]+)/g)].map(mm=>[parseFloat(mm[1]),parseFloat(mm[2])]);\n"
2521+
" const xs=nums.map(p=>p[0]);\n"
2522+
" console.log(JSON.stringify({w: Math.max(...xs)-Math.min(...xs)}));\n"
2523+
"}\n"
2524+
))
2525+
return out["w"]
2526+
2527+
plain_w = render({})
2528+
manual_w = render({"width_cm": 240, "height_cm": 5, "rotation": 0})
2529+
assert plain_w is not None and manual_w is not None, (plain_w, manual_w)
2530+
assert manual_w > plain_w * 2, (
2531+
f"a 240cm manual width must make the aura's outline visibly wider than the "
2532+
f"default icon's ({manual_w} vs {plain_w}) — the manual footprint must reach the morph"
2533+
)

0 commit comments

Comments
 (0)